diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3748eccbd3..b828a6800a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,12 +18,15 @@ on: - "apps/simplex-badge-service/**" - "apps/simplex-broadcast-bot/**" - "apps/simplex-directory-service/**" + - "apps/multiplatform/**" + - "apps/ios/**" - "tests/**" - "bots/src/**" - "simplex-chat.cabal" - "cabal.project" - "Dockerfile*" - "scripts/ci/**" + - "scripts/ci/ios-stub-libs.sh" - "scripts/desktop/**" - ".github/**" @@ -90,7 +93,10 @@ jobs: runs-on: ubuntu-latest outputs: libs_tag: ${{ steps.libs.outputs.tag }} - core_changed: ${{ steps.core.outputs.core }} + core_changed: ${{ steps.changes.outputs.core }} + haskell: ${{ steps.changes.outputs.haskell }} + kotlin: ${{ steps.changes.outputs.kotlin }} + ios: ${{ steps.changes.outputs.ios }} addon_tag: ${{ steps.addon.outputs.tag }} addon_draft: ${{ steps.addon.outputs.draft }} libs_latest: ${{ steps.latest_libs.outputs.tag }} @@ -105,14 +111,23 @@ jobs: with: fetch-depth: 2 - - name: Check chat core change - id: core - if: github.event_name == 'pull_request' + - name: Check changes + id: changes + if: github.event_name == 'push' || github.event_name == 'pull_request' + shell: bash env: BASE: ${{ github.event.pull_request.base.sha }} run: | - git fetch -q --depth=1 origin "$BASE" - python3 scripts/ci/packages_release.py changes "$BASE" | grep '^core=' >> "$GITHUB_OUTPUT" + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py changes "$BASE" + else + echo "haskell=true" + if [ "$GITHUB_REF_TYPE" = branch ]; then + echo "kotlin=true" + echo "ios=true" + fi + fi >> "$GITHUB_OUTPUT" - name: Build changelog id: build_changelog @@ -226,7 +241,7 @@ jobs: os_underscore: 22_04 arch: x86_64 runner: "ubuntu-22.04" - should_run: true + should_run: ${{ needs.maybe-release.outputs.haskell == 'true' }} ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' libs: true @@ -234,12 +249,13 @@ jobs: os_underscore: 24_04 arch: x86_64 runner: "ubuntu-24.04" - should_run: true + should_run: ${{ needs.maybe-release.outputs.haskell == 'true' }} ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' + kotlin: ${{ needs.maybe-release.outputs.kotlin == 'true' }} steps: &linux-steps - name: Checkout Code - if: matrix.should_run == true + if: matrix.should_run == true || matrix.kotlin == true uses: actions/checkout@v3 with: submodules: recursive @@ -258,16 +274,16 @@ jobs: # Otherwise we run out of disk space with Docker build - name: Free disk space - if: matrix.should_run == true + if: matrix.should_run == true || matrix.kotlin == true shell: bash run: ./scripts/ci/linux_util_free_space.sh - name: Set up Docker Buildx - if: matrix.should_run == true + if: matrix.should_run == true || matrix.kotlin == true uses: simplex-chat/docker-setup-buildx-action@v3 - name: Log in to GitHub Container Registry - if: matrix.should_run == true && env.release != 'true' + if: (matrix.should_run == true || matrix.kotlin == true) && env.release != 'true' shell: bash env: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -275,11 +291,11 @@ jobs: - name: Compute image cache week id: week - if: matrix.should_run == true && env.release != 'true' + if: (matrix.should_run == true || matrix.kotlin == true) && env.release != 'true' run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" - name: Build and cache Docker image - if: matrix.should_run == true + if: matrix.should_run == true || matrix.kotlin == true uses: simplex-chat/docker-build-push-action@v6 with: context: . @@ -300,7 +316,7 @@ jobs: # --cap-add SYS_ADMIN # --security-opt apparmor:unconfined - name: Start container - if: matrix.should_run == true + if: matrix.should_run == true || matrix.kotlin == true shell: bash run: | mkdir -p ~/.cabal @@ -620,9 +636,23 @@ jobs: exit 1 fi + # Compiling the desktop and Android targets of :common in parallel exceeds the 2 GB Kotlin daemon heap. + # cmakeBuildAndCopy links the JNI library against libsimplex, which this job does not build. + - name: Build and test Kotlin apps + if: ${{ !cancelled() && matrix.kotlin == true }} + timeout-minutes: 90 + shell: docker exec -t builder sh -eu {0} + run: | + cd apps/multiplatform + ./gradlew --no-daemon --max-workers=1 \ + :common:desktopTest \ + :desktop:compileKotlinJvm -x :desktop:cmakeBuildAndCopy \ + :android:compileGoogleDebugKotlin \ + :android:testFossDebugUnitTest + - name: Setup Node.js id: setup-node - if: ${{ !cancelled() && matrix.libs && (github.event_name == 'pull_request' || needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + if: ${{ !cancelled() && matrix.should_run == true && matrix.libs && (github.event_name == 'pull_request' || needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} uses: actions/setup-node@v7 with: node-version: 22 @@ -741,6 +771,7 @@ jobs: ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' libs: true + kotlin: false - os: 24.04 os_underscore: 24_04 arch: aarch64 @@ -748,12 +779,13 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' + kotlin: false steps: *linux-steps build-linux-ghc8: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] - if: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} + if: ${{ needs.maybe-release.outputs.haskell == 'true' && !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} runs-on: ${{ matrix.runner }} permissions: contents: write @@ -773,6 +805,7 @@ jobs: ghc: "8.10.7" hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' should_run: true + kotlin: false steps: *linux-steps # ================================= @@ -911,31 +944,36 @@ jobs: cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" + should_run: ${{ needs.maybe-release.outputs.haskell == 'true' }} + ios: ${{ needs.maybe-release.outputs.ios == 'true' }} - os: macos-15-intel arch: x86_64 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" + should_run: ${{ needs.maybe-release.outputs.haskell == 'true' }} steps: - name: Checkout Code + if: matrix.should_run == true || matrix.ios == true uses: actions/checkout@v3 with: submodules: recursive - name: Point Command Line Tools SDK to Xcode SDK + if: matrix.should_run == true || matrix.ios == true shell: bash run: sudo ln -sfn "$(xcrun --sdk macosx --show-sdk-path)" /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk - name: Compute toolchain cache key id: toolchain-key - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' shell: bash run: echo "key=ghcup-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-cabal3.10.2.0-$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" - name: Restore toolchain from cache id: restore-toolchain - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' uses: actions/cache/restore@v4 with: path: | @@ -945,11 +983,12 @@ jobs: key: ${{ steps.toolchain-key.outputs.key }} - name: Warn if toolchain not in cache - if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + if: matrix.should_run == true && env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' run: echo "::warning title=Cache miss::${{ steps.toolchain-key.outputs.key }}" - name: Prepare build id: prepare + if: matrix.should_run == true uses: ./.github/actions/prepare-build with: java_ver: ${{ needs.variables.outputs.JAVA_VER }} @@ -957,7 +996,7 @@ jobs: github_ref: ${{ github.ref }} - name: Save toolchain to cache - if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + if: matrix.should_run == true && env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | @@ -967,9 +1006,11 @@ jobs: key: ${{ steps.toolchain-key.outputs.key }} - name: Install OpenSSL + if: matrix.should_run == true run: brew install openssl@3.0 - name: Prepare cabal.project.local + if: matrix.should_run == true shell: bash run: | echo "ignore-project: False" >> cabal.project.local @@ -984,7 +1025,7 @@ jobs: - name: Compute cache key prefixes id: keys - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' shell: bash run: | toolchain=$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16) @@ -994,7 +1035,7 @@ jobs: - name: Restore dependencies from cache id: restore-deps - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' uses: actions/cache/restore@v4 with: path: ${{ steps.prepare.outputs.cabal-store }} @@ -1004,12 +1045,12 @@ jobs: ${{ steps.keys.outputs.deps }}- - name: Warn if dependencies not in cache - if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" - name: Restore source repositories from cache id: restore-src - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' uses: actions/cache/restore@v4 with: path: dist-newstyle/src @@ -1017,12 +1058,12 @@ jobs: restore-keys: src-${{ runner.os }}- - name: Warn if source repositories not in cache - if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + if: matrix.should_run == true && env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' run: echo "::warning title=Cache miss::src-${{ runner.os }}" - name: Restore build directory from cache id: restore-build - if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + if: matrix.should_run == true && env.release != 'true' && !needs.maybe-release.outputs.libs_tag uses: actions/cache/restore@v4 with: path: | @@ -1034,11 +1075,12 @@ jobs: ${{ steps.keys.outputs.dist }}-${{ env.opt }}- - name: Warn if build directory not in cache - if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + if: matrix.should_run == true && steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" - name: Build CLI id: mac_cli_build + if: matrix.should_run == true shell: bash run: | scripts/ci/prune-old-builds.sh dist-newstyle @@ -1049,26 +1091,26 @@ jobs: - name: Compute dependency cache key id: hash-deps - if: env.release != 'true' + if: matrix.should_run == true && env.release != 'true' shell: bash run: echo "hash=$(ls '${{ steps.prepare.outputs.cabal-store }}'/ghc-*/ | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" - name: Save dependencies to cache - if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) uses: actions/cache/save@v4 with: path: ${{ steps.prepare.outputs.cabal-store }} key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} - name: Save source repositories to cache - if: env.release != 'true' && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + if: matrix.should_run == true && env.release != 'true' && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: dist-newstyle/src key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} - name: Save build directory to cache - if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | @@ -1077,7 +1119,7 @@ jobs: key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} - name: Upload CLI - if: startsWith(github.ref, 'refs/tags/v') + if: matrix.should_run == true && startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release with: bin_path: ${{ steps.mac_cli_build.outputs.bin_path }} @@ -1088,7 +1130,7 @@ jobs: - name: Build Desktop id: mac_desktop_build - if: startsWith(github.ref, 'refs/tags/v') + if: matrix.should_run == true && startsWith(github.ref, 'refs/tags/v') shell: bash env: APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} @@ -1102,7 +1144,7 @@ jobs: echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload Desktop - if: startsWith(github.ref, 'refs/tags/v') + if: matrix.should_run == true && startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release with: bin_path: ${{ steps.mac_desktop_build.outputs.package_path }} @@ -1112,7 +1154,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Mount RAM disk for tests - if: matrix.arch == 'aarch64' + if: matrix.should_run == true && matrix.arch == 'aarch64' shell: bash run: | disk=$(hdiutil attach -nomount ram://1048576 | tr -d '[:space:]') @@ -1120,7 +1162,7 @@ jobs: ln -s /Volumes/tests tests/tmp - name: Run tests - if: matrix.arch == 'aarch64' + if: matrix.should_run == true && matrix.arch == 'aarch64' timeout-minutes: 120 shell: bash run: | @@ -1141,9 +1183,26 @@ jobs: exit 1 fi + - name: Build iOS app + if: ${{ !cancelled() && matrix.ios == true }} + timeout-minutes: 60 + shell: bash + run: | + scripts/ci/ios-stub-libs.sh + xcodebuild build -quiet \ + -project apps/ios/SimpleX.xcodeproj \ + -scheme "SimpleX (iOS)" \ + -configuration Debug \ + -destination "generic/platform=iOS Simulator" \ + -onlyUsePackageVersionsFromResolvedFile \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + ARCHS=arm64 \ + CODE_SIGNING_ALLOWED=NO + - name: Build library id: mac_lib_build - if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} shell: bash env: APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} @@ -1158,7 +1217,7 @@ jobs: (cd release-libs/$name && zip -r ../$name.zip libs) - name: Upload library - if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.mac_lib_build.outcome == 'success' }} shell: bash env: GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} @@ -1167,20 +1226,20 @@ jobs: - name: Setup Node.js id: setup-node - if: ${{ !cancelled() && (github.event_name == 'pull_request' || needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + if: ${{ !cancelled() && matrix.should_run == true && (github.event_name == 'pull_request' || needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} uses: actions/setup-node@v7 with: node-version: 22 - name: Setup Python id: setup-python - if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.setup-node.outcome == 'success' }} uses: actions/setup-python@v7 with: python-version: "3.12" - name: Package tests (pull request) - if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && github.event_name == 'pull_request' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.setup-python.outcome == 'success' && github.event_name == 'pull_request' }} timeout-minutes: 30 shell: bash run: | @@ -1188,7 +1247,7 @@ jobs: scripts/ci/package-tests.sh test - name: Add-on - if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + if: ${{ !cancelled() && matrix.should_run == true && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} timeout-minutes: 30 shell: bash env: @@ -1200,7 +1259,7 @@ jobs: - name: Package tests id: package-tests - if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + if: ${{ !cancelled() && matrix.should_run == true && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} timeout-minutes: 30 shell: bash run: | @@ -1208,7 +1267,7 @@ jobs: echo "macos-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" - name: Package tests result - if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.package-tests.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: package-tests-macos-${{ matrix.arch }} @@ -1216,13 +1275,13 @@ jobs: - name: Remove -O2 build directory id: remove-o2 - if: env.cache_o0 == 'true' && !cancelled() && steps.mac_cli_build.outcome == 'success' + if: matrix.should_run == true && env.cache_o0 == 'true' && !cancelled() && steps.mac_cli_build.outcome == 'success' shell: bash run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + - name: Restore -O0 build directory from cache id: restore-build-o0 - if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.remove-o2.outcome == 'success' }} uses: actions/cache/restore@v4 with: path: | @@ -1235,14 +1294,14 @@ jobs: - name: Build at -O0 for pull request caches id: build-o0 - if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.remove-o2.outcome == 'success' }} shell: bash run: | scripts/ci/prune-old-builds.sh dist-newstyle cabal build -j --enable-tests --ghc-options=-O0 - name: Save -O0 build directory to cache - if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + if: ${{ !cancelled() && matrix.should_run == true && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -1257,6 +1316,7 @@ jobs: build-windows: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] + if: needs.maybe-release.outputs.haskell == 'true' runs-on: ${{ matrix.os }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} diff --git a/Dockerfile.build b/Dockerfile.build index 89f8c25101..f18f608612 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -5,11 +5,17 @@ FROM ubuntu:${TAG}@${HASH} AS build ### Build stage +# Ubuntu archive state that all apt packages are installed from; bump it to pick up package updates +ARG UBUNTU_SNAPSHOT=20261021T000000Z ARG GHC=9.6.3 ARG CABAL=3.10.2.0 -ARG JAVA_VER=17.0.17.10.1 -ARG JAVA_HASH_AMD64=e3e11daa5c22a45153bbeff1a0c21bf08631791e4e8d8ed14deba31c7cf9af1a -ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514acbc +ARG GHCUP_VER=0.2.6.2 +ARG GHCUP_HASH_AMD64=9ed5da5449b48043a0d17e767c05d2ef585e25a639bb934329496c6d2fad9cf8 +ARG GHCUP_HASH_ARM64=65a5f05120288ee4f1a81d28825374b6af317456a351a586adfce90c6dc29e3b +ARG GHCUP_METADATA=https://raw.githubusercontent.com/haskell/ghcup-metadata/ff5a77f2ee34afd9c49b970a45abfb7dac01cb8a/ghcup-0.1.0.yaml +ARG JAVA_VER=17.0.21.0.10 +ARG JAVA_HASH_AMD64=8e7577ddeb5b17a13c73a6fe80b592f6e70299cd19f54d06976986b0bc7ada87 +ARG JAVA_HASH_ARM64=c400e8ae70ee9565c23667bc3b4dd0fe5f7c403629ebb2cf9648c1d756b6b926 ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive @@ -18,8 +24,15 @@ ARG USER_UID=1000 ARG USER_GID=1000 ARG USER_NAME=builder +# The base image has no CA certificates, so they are fetched without TLS peer verification; +# apt still verifies every index against the GPG-signed InRelease files. +RUN sed -i -E "s#http://(archive|security|ports)\.ubuntu\.com/ubuntu(-ports)?/?#https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/#" \ + $(grep -rlsE 'http://(archive|security|ports)\.ubuntu\.com' /etc/apt/sources.list /etc/apt/sources.list.d) && \ + apt-get -o Acquire::https::Verify-Peer=false update --error-on=any && \ + apt-get -o Acquire::https::Verify-Peer=false install -y ca-certificates + # Install curl, git and and simplex-chat dependencies -RUN apt-get update && \ +RUN apt-get update --error-on=any && \ apt-get install -y curl \ libpq-dev \ git \ @@ -58,7 +71,7 @@ RUN apt-get update && \ # to fix this :( RUN export JAVA_FILENAME='java-corretto.deb' \ JAVA_VER_MAJOR=$(printf "${JAVA_VER}" | awk -F. '{print $1}') \ - JAVA_VER_DEB=$(printf "${JAVA_VER}" | sed 's/\.1$/-1/') && \ + JAVA_VER_DEB="${JAVA_VER}-1" && \ case "$(uname -m)" in \ x86_64) export JAVA_ARCH='amd64' JAVA_HASH="$JAVA_HASH_AMD64" ;; \ aarch64) export JAVA_ARCH='arm64' JAVA_HASH="$JAVA_HASH_ARM64" ;; \ @@ -83,35 +96,38 @@ RUN mkdir /nix /out && chown ${USER_NAME}:${USER_NAME} /nix /out USER ${USER_NAME} WORKDIR /home/${USER_NAME} -# 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 - # Setup basic env variables (required) ENV HOME="/home/${USER_NAME}" USER="${USER_NAME}" # Adjust PATH ENV PATH="$HOME/.cabal/bin:$HOME/.ghcup/bin:$PATH" -# Set both as default -RUN ghcup set ghc "${GHC}" && \ - ghcup set cabal "${CABAL}" +# Install ghcup, GHC and cabal +RUN case "$(uname -m)" in \ + x86_64) export GHCUP_HASH="$GHCUP_HASH_AMD64" ;; \ + aarch64) export GHCUP_HASH="$GHCUP_HASH_ARM64" ;; \ + *) echo "unknown arch $(uname -m)" && exit 1 ;; \ + esac && \ + mkdir -p "$HOME/.ghcup/bin" && \ + curl --proto '=https' --tlsv1.2 -sSf \ + "https://downloads.haskell.org/~ghcup/${GHCUP_VER}/$(uname -m)-linux-ghcup-${GHCUP_VER}" \ + -o "$HOME/.ghcup/bin/ghcup" && \ + echo "${GHCUP_HASH} $HOME/.ghcup/bin/ghcup" | sha256sum -c - && \ + chmod +x "$HOME/.ghcup/bin/ghcup" && \ + ghcup config set url-source "${GHCUP_METADATA}" && \ + ghcup install ghc "${GHC}" --set && \ + ghcup install cabal "${CABAL}" --set #===================== # Install Android SDK #===================== ARG SDK_VERSION=13114758 +ARG SDK_HASH=7ec965280a073311c339e571cd5de778b9975026cfcbe79f2b1cdcb1e15317ee ENV SDK_VERSION="$SDK_VERSION" \ ANDROID_HOME="$HOME" RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \ + echo "${SDK_HASH} tools.zip" | sha256sum -c - && \ 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" @@ -128,7 +144,13 @@ ENV PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools" # Android reproducibility scripts RUN python3 -m venv "$HOME/.venv" -RUN "$HOME/.venv/bin/pip" install apksigcopier repro-apk +RUN printf '%s\n' \ + 'apksigcopier==1.1.1 --hash=sha256:0834bb7d6f3607bb0994cf8cd2e26b8f0c9d481e13b75872e0fa9cec052e519b' \ + 'repro-apk==0.3.0 --hash=sha256:09091a5e6d725d73ad418aa98ec72d0737452d3ce3803b56dd5c270b887fceb9' \ + 'click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360' \ + > /tmp/requirements.txt && \ + "$HOME/.venv/bin/pip" install --require-hashes --only-binary=:all: -r /tmp/requirements.txt && \ + rm /tmp/requirements.txt ENV PATH="$HOME/.venv/bin:$PATH" diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 30fa5c67b5..72cbd5d69b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1648,6 +1648,7 @@ struct AddressSettings: Codable, Hashable { var businessAddress: Bool var autoAccept: AutoAccept? var autoReply: MsgContent? + var chatLayout: ChatLayout? } struct AutoAccept: Codable, Hashable { @@ -2326,6 +2327,7 @@ struct AppSettings: Codable, Equatable { var uiThemes: [ThemeOverrides]? = nil var oneHandUI: Bool? = nil var chatBottomBar: Bool? = nil + var uiChatLayout: ChatLayout? = nil func prepareForExport() -> AppSettings { var empty = AppSettings() @@ -2362,6 +2364,7 @@ struct AppSettings: Codable, Equatable { if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } + if uiChatLayout != def.uiChatLayout { empty.uiChatLayout = uiChatLayout } return empty } @@ -2398,7 +2401,8 @@ struct AppSettings: Codable, Equatable { uiCurrentThemeIds: nil as [String: String]?, uiThemes: nil as [ThemeOverrides]?, oneHandUI: true, - chatBottomBar: true + chatBottomBar: true, + uiChatLayout: .classic ) } } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 15d36f7931..f2fc9ffb8e 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -876,7 +876,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate { guard let connectionStateString = newState.toString() else { return } Task { - if activeCall?.wasConnected == true { + if activeCall?.wasConnected == true && ChatModel.shared.activeCall?.contact.callReconnectSupported == true { switch newState { case .disconnected, .failed: if activeCall?.reconnection == nil { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 3d1e7ef477..7857646177 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -1023,14 +1023,14 @@ struct ChatWallpaperEditorSheet: View { let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile)) if currentMode == DefaultThemeMode.light { dark.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark) + changedThemes?.dark = dark } else { light.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark) + changedThemes?.light = light } } } else { - changedThemes = nil + changedThemes = changedThemes?.layoutOnly() } wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile) wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile) diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 3554ce720f..849abb4d62 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -67,6 +67,7 @@ extension AppSettings { if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) } if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) } if let val = chatBottomBar { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) } + if let val = uiChatLayout { chatLayoutDefault.set(val) } } public static var current: AppSettings { @@ -104,6 +105,7 @@ extension AppSettings { c.uiThemes = themeOverridesDefault.get() c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI) c.chatBottomBar = groupDefaults.bool(forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) + c.uiChatLayout = chatLayoutDefault.get() return c } } diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 54a60eed19..d45ca12f75 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -775,14 +775,14 @@ struct UserWallpaperEditorSheet: View { let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile)) if currentMode == DefaultThemeMode.light { dark.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark) + changedThemes?.dark = dark } else { light.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark) + changedThemes?.light = light } } } else { - changedThemes = nil + changedThemes = changedThemes?.layoutOnly() } wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile) wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile) @@ -1135,7 +1135,7 @@ private func saveThemeToDatabase(_ themeUserDestination: (Int64, ThemeModeOverri private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64, ThemeModeOverrides?)?>, _ perUserTheme: Binding) { guard let dest = themeUserDestination.wrappedValue else { return } perUserTheme.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode) - themeUserDestination.wrappedValue = (dest.0, nil) + themeUserDestination.wrappedValue = (dest.0, dest.1?.layoutOnly()) var wallpaperFilesToDelete: [String] = [] if let type = ChatModel.shared.currentUser?.uiThemes?.light?.type, case let WallpaperType.image(filename, _, _) = type { wallpaperFilesToDelete.append(filename) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f7042b0c39..493bf7914d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,7 @@ let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" +let DEFAULT_CHAT_LAYOUT = "chatLayout" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" @@ -123,6 +124,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness, DEFAULT_CHAT_ITEM_TAIL: true, + DEFAULT_CHAT_LAYOUT: ChatLayout.classic.rawValue, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_SUPPORTER_BANNER_SHOWN: false, @@ -214,6 +216,8 @@ let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.stan let connectViaLinkTabDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan) +let chatLayoutDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_LAYOUT, withDefault: .classic) + let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 61c808dda1..f25733a002 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -582,6 +582,7 @@ struct AddressSettingsState: Equatable { var autoAccept = false var autoAcceptIncognito = false var autoReply = "" + var chatLayout: ChatLayout? init() {} @@ -590,13 +591,15 @@ struct AddressSettingsState: Equatable { self.autoAccept = settings.autoAccept != nil self.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true self.autoReply = settings.autoReply?.text ?? "" + self.chatLayout = settings.chatLayout } var addressSettings: AddressSettings { AddressSettings( businessAddress: self.businessAddress, autoAccept: self.autoAccept ? AutoAccept(acceptIncognito: self.autoAcceptIncognito) : nil, - autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply) + autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply), + chatLayout: self.chatLayout ) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3977f562bd..86b678ea83 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -19,6 +19,8 @@ public let REPORTS_VERSION = 12 // support group knocking (MsgScope) public let GROUP_KNOCKING_VERSION = 15 +public let CALL_RECONNECT_VERSION = 22 + 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 { @@ -259,6 +261,41 @@ extension ChatPeerType: Codable { } } +public enum ChatLayout: Hashable { + case classic + case terminal + case unknown(String) +} + +extension ChatLayout: RawRepresentable { + public init(rawValue: String) { + switch rawValue { + case "classic": self = .classic + case "terminal": self = .terminal + default: self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .classic: "classic" + case .terminal: "terminal" + case let .unknown(s): s + } + } +} + +extension ChatLayout: Codable { + public init(from decoder: Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(rawValue) + } +} + public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image { switch peerType { case .bot: Image(systemName: "cube.fill") @@ -2578,6 +2615,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var active: Bool { get { contactStatus == .active } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } + public var callReconnectSupported: Bool { (activeConn?.peerChatVRange.maxVersion ?? 0) >= CALL_RECONNECT_VERSION } public var nextConnectPrepared: Bool { active && preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) } public var profileChangeProhibited: Bool { activeConn != nil } public var nextAcceptContactRequest: Bool { @@ -3142,6 +3180,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var publicGroup: PublicGroupProfile? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? + public var chatLayout: ChatLayout? public var localAlias: String { "" } public var memberAdmission_: GroupMemberAdmission { @@ -3189,6 +3228,7 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + public var chatLayout: ChatLayout? // set by the core when building the connection plan: the link profile's badge, verified and crypto-free public var localBadge: LocalBadge? } diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index a4e8050c6e..f8e800f15e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -570,15 +570,21 @@ extension [ThemeOverrides] { public struct ThemeModeOverrides: Codable, Hashable { public var light: ThemeModeOverride? = nil public var dark: ThemeModeOverride? = nil + public var chatLayout: ChatLayout? = nil - public init(light: ThemeModeOverride? = nil, dark: ThemeModeOverride? = nil) { + public init(light: ThemeModeOverride? = nil, dark: ThemeModeOverride? = nil, chatLayout: ChatLayout? = nil) { self.light = light self.dark = dark + self.chatLayout = chatLayout } public func preferredMode(_ darkTheme: Bool) -> ThemeModeOverride? { darkTheme ? dark : light } + + public func layoutOnly() -> ThemeModeOverrides? { + chatLayout.map { ThemeModeOverrides(chatLayout: $0) } + } } // Spec: spec/services/theme.md#ThemeModeOverride diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index e656a2932a..5032a3a3ed 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -109,7 +109,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core. 3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way. 4. When the connection is restored, the call returns to connected state. -5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case. +5. If the connection is not restored in 30 seconds, the call ends. If the contact's chat version is below 22 (`CALL_RECONNECT_VERSION`), the call ends as soon as the connection is lost, without reconnection. ### 7. Audio Routing diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index b3fb83d151..08f256449b 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -184,7 +184,7 @@ All call signaling travels through the same encrypted SMP message channels used ### Reconnection -When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. +When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. Reconnection happens only when the contact's peer chat version is at least `CALL_RECONNECT_VERSION` (22); otherwise the call ends as soon as the connection is lost. ### API Commands diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md index 321f3307f9..3b14e7175b 100644 --- a/apps/ios/spec/services/theme.md +++ b/apps/ios/spec/services/theme.md @@ -91,7 +91,7 @@ struct ActiveTheme: Equatable { 1. Determine base theme from `currentThemeDefault`: - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) -2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L656), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L635), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L677), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L698)) 3. Look up app settings theme override (`themeOverridesDefault`) 4. Look up per-user theme override (`User.uiThemes`) 5. Look up per-chat theme override (from ChatInfo) @@ -135,10 +135,10 @@ Each base theme defines two palette types: - [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) Palette instances: -- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) -- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) -- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) -- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L656) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L668) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L635) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L647) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L677) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L689) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L698) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L710) --- @@ -168,10 +168,13 @@ Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in struct ThemeModeOverrides: Codable { var light: ThemeModeOverride? var dark: ThemeModeOverride? + var chatLayout: ChatLayout? } ``` -#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) +`chatLayout` is set by the Android and desktop apps; iOS keeps it in theme writes: "Reset to app theme" and "Remove image" keep only the layout (`layoutOnly()`). + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L591) ```swift struct ThemeModeOverride: Codable { @@ -374,7 +377,7 @@ Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/Ap | File | Path | Key Definitions | |------|------|-----------------| | Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | -| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L591) | | Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | | Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | | App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index e9503bc6ce..0ed62884ba 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -146,7 +146,11 @@ kotlin { implementation("com.squareup.okhttp3:okhttp:4.12.0") } } - val desktopTest by getting + val desktopTest by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } } } 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 e19961126d..e38f5f9ce0 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 @@ -414,6 +414,13 @@ object ChatModel { } } + suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } + } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) @@ -584,9 +591,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { - chat = chatsContext.chats[i] + chat = 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) { + if (cInfo.inMainChatList) { val newPreviewItem = when (cInfo) { is ChatInfo.Group -> { val currentPreviewItem = chat.chatItems.firstOrNull() @@ -604,20 +611,20 @@ object ChatModel { else -> cItem } val wasUnread = chat.unreadTag - chatsContext.chats[i] = chat.copy( + chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { + if (cItem.meta.itemStatus is CIStatus.RcvNew && secondaryContextFilter == null) { 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) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) } // pop chat if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chatsContext.chats[i], 0) + reorderChat(chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } @@ -1858,6 +1865,24 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> emptyList() } + val chatLayout: ChatLayoutMode? get() = when (this) { + is Direct -> contact.chatLayout + is Group -> groupInfo.groupProfile.chatLayout + else -> null + } + + val uiThemes: ThemeModeOverrides? get() = when (this) { + is Direct -> contact.uiThemes + is Group -> groupInfo.uiThemes + else -> null + } + + fun defaultChatLayout(user: User?): ChatLayoutMode = + chatLayout.known ?: profileChatLayout(user) + + fun activeChatLayout(user: User?): ChatLayoutMode = + uiThemes?.chatLayout.known ?: defaultChatLayout(user) + val contactCard: Boolean get() = when (this) { is Direct -> contact.isContactCard @@ -1872,6 +1897,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val isChannel: Boolean get() = groupInfo_?.useRelays == true + + val inMainChatList: Boolean + get() = groupChatScope() == null || groupInfo_?.membership?.memberPending == true } @Serializable @@ -1898,6 +1926,7 @@ data class Contact( val chatItemTTL: Long?, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, + val chatLayout: ChatLayoutMode? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Direct override val id get() = "@$contactId" @@ -1907,6 +1936,7 @@ data class Contact( val active get() = contactStatus == ContactStatus.Active override val nextConnect get() = sendMsgToConnect val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent + val callReconnectSupported get() = (activeConn?.peerChatVRange?.maxVersion ?: 0) >= CALL_RECONNECT_VERSION override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) override val profileChangeProhibited get() = activeConn != null val nextAcceptContactRequest get() = @@ -2191,6 +2221,39 @@ object ChatPeerTypeSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text) } +@Serializable(with = ChatLayoutModeSerializer::class) +sealed class ChatLayoutMode { + @Serializable @SerialName("classic") object Classic: ChatLayoutMode() + @Serializable @SerialName("terminal") object Terminal: ChatLayoutMode() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ChatLayoutMode() + + val text: String + get() = when (this) { + is Classic -> "classic" + is Terminal -> "terminal" + is Unknown -> type + } + + companion object { + fun fromText(text: String): ChatLayoutMode = when (text) { + "classic" -> Classic + "terminal" -> Terminal + else -> Unknown(text) + } + } +} + +object ChatLayoutModeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatLayoutMode", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ChatLayoutMode = ChatLayoutMode.fromText(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: ChatLayoutMode) = encoder.encodeString(value.text) +} + +private val ChatLayoutMode?.known: ChatLayoutMode? get() = if (this is ChatLayoutMode.Unknown) null else this + +fun profileChatLayout(user: User?): ChatLayoutMode = + user?.uiThemes?.chatLayout.known ?: ChatController.appPrefs.chatLayout.state.value.known ?: ChatLayoutMode.Classic + fun peerTypeIcon(peerType: ChatPeerType?): ImageResource = when (peerType) { ChatPeerType.Bot -> MR.images.ic_cube @@ -2759,7 +2822,8 @@ data class GroupProfile ( val publicGroup: PublicGroupProfile? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, - val memberAdmission: GroupMemberAdmission? = null + val memberAdmission: GroupMemberAdmission? = null, + val chatLayout: ChatLayoutMode? = null ): NamedChat { val isChannel: Boolean get() = publicGroup?.groupType == GroupType.Channel @@ -2798,6 +2862,7 @@ data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, val business: Boolean, + val chatLayout: ChatLayoutMode? = null, // set by the core when building the connection plan: the link profile's badge, verified and crypto-free val localBadge: LocalBadge? = null ) 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 97a8a37990..bc4b521f9d 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 @@ -74,6 +74,8 @@ val CREATE_MEMBER_CONTACT_VERSION = 2 // support group knocking (MsgScope) val GROUP_KNOCKING_VERSION = 15 +val CALL_RECONNECT_VERSION = 22 + enum class CallOnLockScreen { DISABLE, SHOW, @@ -277,6 +279,10 @@ class AppPreferences { val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true) val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true) + val chatLayout = SharedPreference( + get = fun() = ChatLayoutMode.fromText(settings.getString(SHARED_PREFS_CHAT_LAYOUT, ChatLayoutMode.Classic.text)), + set = fun(value: ChatLayoutMode) = settings.putString(SHARED_PREFS_CHAT_LAYOUT, value.text) + ) val hintPreferences: List = listOf( hintPref(laNoticeShown, false), @@ -499,6 +505,7 @@ class AppPreferences { private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI" private const val SHARED_PREFS_CHAT_BOTTOM_BAR = "ChatBottomBar" + private const val SHARED_PREFS_CHAT_LAYOUT = "ChatLayout" private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used @@ -3705,7 +3712,8 @@ object ChatController { media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, - relay = useRelay + relay = useRelay, + canReconnect = r.contact.callReconnectSupported )) } @@ -7820,7 +7828,8 @@ data class UserContactLinkRec( data class AddressSettings( val businessAddress: Boolean, val autoAccept: AutoAccept?, - val autoReply: MsgContent? + val autoReply: MsgContent?, + val chatLayout: ChatLayoutMode? = null ) @Serializable @@ -8776,7 +8785,8 @@ data class AppSettings( var uiCurrentThemeIds: Map? = null, var uiThemes: List? = null, var oneHandUI: Boolean? = null, - var chatBottomBar: Boolean? = null + var chatBottomBar: Boolean? = null, + var uiChatLayout: ChatLayoutMode? = null ) { fun prepareForExport(): AppSettings { val empty = AppSettings() @@ -8814,6 +8824,7 @@ data class AppSettings( if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes } if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI } if (chatBottomBar != def.chatBottomBar) { empty.chatBottomBar = chatBottomBar } + if (uiChatLayout != def.uiChatLayout) { empty.uiChatLayout = uiChatLayout } return empty } @@ -8862,6 +8873,7 @@ data class AppSettings( uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) } oneHandUI?.let { def.oneHandUI.set(it) } chatBottomBar?.let { if (appPlatform.isAndroid) def.chatBottomBar.set(it) else def.chatBottomBar.set(true) } + uiChatLayout?.let { def.chatLayout.set(it) } } companion object { @@ -8900,6 +8912,7 @@ data class AppSettings( uiThemes = null, oneHandUI = true, chatBottomBar = true, + uiChatLayout = ChatLayoutMode.Classic, ) val current: AppSettings @@ -8938,7 +8951,8 @@ data class AppSettings( uiCurrentThemeIds = def.currentThemeIds.get(), uiThemes = def.themeOverrides.get(), oneHandUI = def.oneHandUI.get(), - chatBottomBar = def.chatBottomBar.get() + chatBottomBar = def.chatBottomBar.get(), + uiChatLayout = def.chatLayout.get() ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index b6d6a4d771..aade56f305 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatLayoutMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex @@ -474,14 +475,20 @@ fun List.skipDuplicates(): List { @Serializable data class ThemeModeOverrides ( val light: ThemeModeOverride? = null, - val dark: ThemeModeOverride? = null + val dark: ThemeModeOverride? = null, + val chatLayout: ChatLayoutMode? = null ) { fun preferredMode(darkTheme: Boolean): ThemeModeOverride? = when (darkTheme) { false -> light else -> dark } + + fun layoutOnly(): ThemeModeOverrides? = chatLayout?.let { ThemeModeOverrides(chatLayout = it) } } +fun ThemeModeOverrides?.withChatLayout(chatLayout: ChatLayoutMode?): ThemeModeOverrides? = + (this ?: ThemeModeOverrides()).copy(chatLayout = chatLayout).takeIf { it != ThemeModeOverrides() } + // Spec: spec/services/theme.md#ThemeModeOverride @Serializable data class ThemeModeOverride ( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index 89865689c6..3265e1c8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -62,7 +62,8 @@ class CallManager(val chatModel: ChatModel) { media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, - relay = useRelay + relay = useRelay, + canReconnect = invitation.contact.callReconnectSupported )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 86e826e946..6be070631f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -90,8 +90,8 @@ enum class CallState { sealed class WCallCommand { @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("permission") data class Permission(val title: String, val chrome: String, val safari: String): WCallCommand() - @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() - @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() + @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null, val canReconnect: Boolean = false): WCallCommand() + @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null, val canReconnect: Boolean = false): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand() @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand() 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 82575984f6..16834a2ae9 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 @@ -40,6 +40,7 @@ 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.chat.item.LocalTerminalLayout import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* @@ -1296,13 +1297,40 @@ fun ModalData.ChatWallpaperEditorModal(chat: Chat) { globalThemeUsed.value = preferred == null preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - ChatWallpaperEditor( - initialTheme, - applyToMode = if (themes.light == themes.dark) null else initialTheme.mode, - globalThemeUsed = globalThemeUsed, - save = { applyToMode, newTheme -> - save(applyToMode, newTheme, chatModel.getChat(chat.id) ?: chat) - }) + CompositionLocalProvider(LocalTerminalLayout provides (chat.chatInfo.activeChatLayout(chatModel.currentUser.value) == ChatLayoutMode.Terminal)) { + ChatWallpaperEditor( + initialTheme, + applyToMode = if (themes.light == themes.dark) null else initialTheme.mode, + globalThemeUsed = globalThemeUsed, + layoutSection = { ChatLayoutSection(chat.remoteHostId, chat.chatInfo) }, + save = { applyToMode, newTheme -> + save(applyToMode, newTheme, chatModel.getChat(chat.id) ?: chat) + }) + } +} + +@Composable +private fun ChatLayoutSection(rhId: Long?, chatInfo: ChatInfo) { + val defaultChatLayout = chatInfo.defaultChatLayout(chatModel.currentUser.value) + val otherLayout = otherChatLayout(defaultChatLayout) + val defaultSuffix = generalGetString(if (chatInfo.chatLayout == defaultChatLayout) MR.strings.chat_layout_chat_suffix else MR.strings.chat_layout_default_suffix) + val values = listOf( + null to "${chatLayoutName(defaultChatLayout)} $defaultSuffix", + otherLayout to chatLayoutName(otherLayout) + ) + val userChatLayout = chatInfo.uiThemes?.chatLayout + val selection = remember(userChatLayout, otherLayout) { mutableStateOf(if (userChatLayout == otherLayout) otherLayout else null) } + SectionView { + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + values, + selection, + onSelected = { layout -> + selection.value = layout + withBGApi { setChatLayout(rhId, chatInfo, layout) } + } + ) + } } suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, chat: Chat) { @@ -1335,7 +1363,7 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c } changedThemes } else { - null + changedThemes?.layoutOnly() } val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile wallpaperFilesToDelete.forEach(::removeWallpaperFile) @@ -1351,6 +1379,19 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c } } +private suspend fun setChatLayout(rhId: Long?, chatInfo: ChatInfo, layout: ChatLayoutMode?) { + val themes = chatInfo.uiThemes.withChatLayout(layout) + if (controller.apiSetChatUIThemes(rhId, chatInfo.id, themes)) { + withContext(Dispatchers.Main) { + if (chatInfo is ChatInfo.Direct) { + chatModel.chatsContext.updateChatInfo(rhId, chatInfo.copy(contact = chatInfo.contact.copy(uiThemes = themes))) + } else if (chatInfo is ChatInfo.Group) { + chatModel.chatsContext.updateChatInfo(rhId, chatInfo.copy(groupInfo = chatInfo.groupInfo.copy(uiThemes = themes))) + } + } + } +} + private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { 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 f9d32892ee..cad263144f 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 @@ -111,12 +111,15 @@ data class MergedItems ( sealed class MergedItem { abstract val startIndexInReversedItems: Int + abstract val revealed: Boolean // the item that is always single, cannot be grouped and always revealed data class Single( val item: ListItem, override val startIndexInReversedItems: Int, - ): MergedItem() + ): MergedItem() { + override val revealed: Boolean get() = true + } /** 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 @@ -125,7 +128,7 @@ sealed class MergedItem { @Stable data class Grouped ( val items: ArrayList, - val revealed: Boolean, + override val revealed: Boolean, // 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 */ 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 e9dbb3b157..9b872c59c4 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 @@ -5,6 +5,7 @@ import androidx.compose.animation.core.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -264,9 +265,11 @@ fun ChatView( } } val clipboard = LocalClipboardManager.current + val terminal = chatInfo.activeChatLayout(user) == ChatLayoutMode.Terminal CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), LocalSelectionManager provides selectionManager, + LocalTerminalLayout provides terminal, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -723,35 +726,39 @@ fun ChatView( ciInfo } groupMembersJob.cancel() - groupMembersJob = scope.launch(Dispatchers.Default) { - var initialCiInfo = loadChatItemInfo() ?: return@launch + groupMembersJob = scope.launch { + var initialCiInfo = withContext(Dispatchers.Default) { loadChatItemInfo() } ?: return@launch if (!ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() + } else if (ModalManager.end.isTopModalOpen(ModalViewId.CHAT_ITEM_INFO)) { + ModalManager.end.closeModal() } - ModalManager.end.showModalCloseable(endButtons = { + ModalManager.end.showModalCloseable(id = ModalViewId.CHAT_ITEM_INFO, endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> - var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - val (item, info) = ciInfo - ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) - LaunchedEffect(cItem.id) { - withContext(Dispatchers.Default) { - 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 + key(this) { + var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } + val (item, info) = ciInfo + ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) + LaunchedEffect(cItem.id) { + withContext(Dispatchers.Default) { + 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 + } } } } - } - KeyChangeEffect(chatModel.chatId.value) { - close() + KeyChangeEffect(chatModel.chatId.value) { + close() + } } } } @@ -1000,7 +1007,7 @@ 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, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null, drawWallpaperImage = !LocalTerminalLayout.current)) { val remoteHostId = remember { remoteHostId }.value val chat = remember { chat }.value val chatInfo = chat?.chatInfo @@ -1926,6 +1933,7 @@ fun BoxScope.ChatItemsList( cItem: ChatItem, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean, + terminalGap: TextUnit, revealed: State, reveal: (Boolean) -> Unit ) { @@ -1950,11 +1958,10 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f, highlighted: State = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } }, bubbleInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }) { tryOrShowError("${cItem.id}ChatItem", error = { - CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + CIBrokenComposableView(if (cItem.chatDir.sent && !LocalTerminalLayout.current) Alignment.CenterEnd else Alignment.CenterStart) }) { - val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } LaunchedEffect(Unit) { snapshotFlow { highlighted.value } .distinctUntilChanged() @@ -1964,7 +1971,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, 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, swipeOffset = swipeOffset) + ChatItemView(chatsCtx, remoteHostId, chat, 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, swipeOffset = swipeOffset, bubbleInteractionSource = bubbleInteractionSource) } } @@ -1990,18 +1997,19 @@ fun BoxScope.ChatItemsList( swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, ) val sent = cItem.chatDir.sent + val itemTopGap = if (previousItemSeparationLargeGap) 4.dp else 1.dp @Composable - fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { + fun ChatItemBox(modifier: Modifier = Modifier, top: Dp = itemTopGap, bottom: Dp? = null, content: @Composable () -> Unit = { }) { Box( modifier = modifier.padding( bottom = if (itemSeparation.largeGap) { if (itemAtZeroIndexInWholeList) { 8.dp } else { - 4.dp + bottom ?: 4.dp } - } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp + } else bottom ?: 1.dp, top = top ), contentAlignment = Alignment.CenterStart ) { @@ -2021,9 +2029,33 @@ fun BoxScope.ChatItemsList( 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 terminal = LocalTerminalLayout.current + val selectionOffset by animateDpAsState(if (selectionVisible && (!sent || terminal)) 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 (terminal) { + val rowInteractionSource = remember { TerminalRowInteractionSource() } + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + val rowColor = animateColorAsState( + when { + selectedChatItems.value?.contains(cItem.id) == true -> MaterialTheme.colors.primary.copy(alpha = 0.1f) + highlighted.value -> MaterialTheme.colors.primary.copy(alpha = 0.14f) + else -> Color.Transparent + } + ) + ChatItemBox(Modifier.drawBehind { drawRect(rowColor.value) }.terminalRowIndication(rowInteractionSource), top = 0.dp, bottom = if (itemSeparation.date != null) TERMINAL_LEVEL_STEP.toDp() else 0.dp) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + TerminalChatItemRow( + if (showAvatar) chatItemSender(chatInfo, cItem, range, reversedChatItems, showMemberInfo, showChatInfo) else null, + terminalGutter(chatInfo), + terminalGap.toDp(), + Modifier.terminalStartOffset { selectionOffset }.then(if (selectionVisible) Modifier else swipeableModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value, highlighted = highlighted, bubbleInteractionSource = rowInteractionSource) + } + } + } else if (chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { if (showAvatar) { Column( @@ -2424,21 +2456,26 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem) + val older = merged.oldest().nextItem + val showAvatar = if (LocalTerminalLayout.current) terminalItemShowsSender(item, older) else shouldShowAvatar(item, older) + val terminalGap = if (!LocalTerminalLayout.current) 0.sp else terminalRowGap( + item, older, + separated = terminalRowsSeparated(mergedItemsValue.items.getOrNull(index + 1)), + largeGap = older != null && getItemSeparationLargeGap(older, merged.oldest().item), + hasSenderRow = showAvatar + ) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } - val itemSeparation: ItemSeparation + val itemSeparation = rowItemSeparation(merged) val prevItemSeparationLargeGap: Boolean if (merged is MergedItem.Single || isRevealed.value) { val prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) } else { - itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, terminalGap, isRevealed) { if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) } } @@ -3501,7 +3538,7 @@ private fun markUnreadChatAsRead(chatId: String) { } @Composable -private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { +fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { val name = member.displayName val prevName = prevMember?.displayName return if (prevName != null) { @@ -3520,9 +3557,10 @@ fun Modifier.chatViewBackgroundModifier( wallpaper: AppWallpaper, backgroundGraphicsLayerSize: MutableState?, backgroundGraphicsLayer: GraphicsLayer?, - drawWallpaper: Boolean + drawWallpaper: Boolean, + drawWallpaperImage: Boolean ): Modifier { - val wallpaperImage = wallpaper.type.image + val wallpaperImage = if (drawWallpaperImage) wallpaper.type.image else null val wallpaperType = wallpaper.type val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base) @@ -3771,6 +3809,16 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa ) } +private fun rowItemSeparation(row: MergedItem): ItemSeparation { + val newest = row.newest() + return getItemSeparation(newest.item, if (row.revealed) newest.prevItem else null) +} + +fun terminalRowsSeparated(olderRow: MergedItem?): Boolean { + if (olderRow == null) return false + return olderRow.newest().item.content is CIContent.ChatBanner || rowItemSeparation(olderRow).date != null +} + private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { if (nextItem == null) { return true 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 357a010d2c..736bb919e5 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 @@ -333,7 +333,7 @@ fun showLargeFileAlert(fileSize: Long, incognito: Boolean, senderProfile: LocalP text = text, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title, text) @@ -599,7 +599,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -741,7 +741,7 @@ fun ComposeView( withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.addSentChatItem(chatsCtx, rhId, chat.chatInfo, chatItem) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index d3cc76fb7a..9eb00e6cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -25,30 +25,32 @@ fun CICallItemView( timedMessagesTTL: Int? ) { val sent = cItem.chatDir.sent - Column( - Modifier - .padding(horizontal = 4.dp) - .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - @Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen) - when (status) { - CICallStatus.Pending -> if (sent) { - Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent)) - } else { - AcceptCallButton(cInfo, acceptCall) + ItemWithMeta(meta = { CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Column( + Modifier + .padding(horizontal = if (LocalTerminalLayout.current) CHAT_ITEM_TEXT_PADDING else 4.dp) + .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + @Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen) + when (status) { + CICallStatus.Pending -> if (sent) { + Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent)) + } else { + AcceptCallButton(cInfo, acceptCall) + } + CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red) + CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red) + CICallStatus.Accepted -> ConnectingCallIcon() + CICallStatus.Negotiated -> ConnectingCallIcon() + CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen) + CICallStatus.Ended -> Row { + Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp)) + Text(durationText(duration), color = MaterialTheme.colors.secondary) + } + CICallStatus.Error -> {} } - CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red) - CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red) - CICallStatus.Accepted -> ConnectingCallIcon() - CICallStatus.Negotiated -> ConnectingCallIcon() - CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen) - CICallStatus.Ended -> Row { - Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp)) - Text(durationText(duration), color = MaterialTheme.colors.secondary) - } - CICallStatus.Error -> {} - } - CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) + inlineMeta?.invoke() + } } } 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 c743d78d1c..1b5c6e3eaf 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 @@ -8,11 +8,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp 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.helpers.toDp @Composable fun CIChatFeatureView( @@ -35,9 +37,11 @@ fun CIChatFeatureView( .onRightClick { showMenu.value = true } ) { if (!revealed.value && merged != null) { + val terminal = LocalTerminalLayout.current Row( - Modifier.padding(horizontal = 6.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) + Modifier.padding(chatEventPadding(vertical = 8.dp)).then(if (terminal) Modifier.height(chatEventTextStyle().lineHeight.toDp()) else Modifier), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = if (terminal) Alignment.CenterVertically else Alignment.Top ) { merged.forEach { FeatureIconView(it) @@ -102,11 +106,11 @@ private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? = @Composable private fun FeatureIconView(f: FeatureInfo) { - val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) } + val icon = @Composable { Icon(f.icon.icon, null, Modifier.inlineIcon().size(20.dp), tint = f.color) } if (f.param != null) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { icon() - Text(chatEventText(f.param, ""), maxLines = 1) + Text(chatEventText(f.param, AnnotatedString(" ")), maxLines = 1, style = chatEventTextStyle()) } } else { icon() @@ -120,17 +124,20 @@ private fun FullFeatureView( iconColor: Color, icon: Painter? = null ) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor) - Text( - chatEventText(chatItem), - Modifier, - // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp - fontSize = 12.sp - ) + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(icon ?: feature.iconFilled(), feature.text, Modifier.inlineIcon().size(20.dp), tint = iconColor) + Text( + chatEventText(chatItem, ts = separator + ts), + Modifier, + // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp + fontSize = 12.sp, + style = chatEventTextStyle() + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 529a0e458b..afba9f87f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,18 +1,50 @@ package chat.simplex.common.views.chat.item import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.* +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.ChatItem import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.toDp @Composable fun CIEventView(text: AnnotatedString) { - Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), maxLines = 4) + Text(text, Modifier.padding(chatEventPadding()), style = chatEventTextStyle(MaterialTheme.typography.body1.copy(lineHeight = 22.sp)), maxLines = if (LocalTerminalLayout.current) Int.MAX_VALUE else 4) +} + +@Composable +fun chatEventTextStyle(base: TextStyle = LocalTextStyle.current): TextStyle = + if (LocalTerminalLayout.current) { + base.merge(terminalTextStyle(12.sp, MaterialTheme.colors.secondary)) + } else { + base + } + +@Composable +fun chatEventPadding(vertical: Dp = 6.dp): PaddingValues = + if (LocalTerminalLayout.current) PaddingValues(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EVENT_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EVENT_BOTTOM_GAP.toDp()) + else PaddingValues(horizontal = 6.dp, vertical = vertical) + +@Composable +fun ChatEventWithMeta(ci: ChatItem, content: @Composable (separator: AnnotatedString, ts: AnnotatedString) -> Unit) { + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = true) }, metaReserved = true) { inlineMeta -> + if (inlineMeta != null) { + content(AnnotatedString(" "), AnnotatedString(ci.timestampText)) + } else { + val copy = terminalReserveSpaceForMeta(ci.meta, chatTTL = null, encrypted = null, signedFileVerified = null, secondaryColor = MaterialTheme.colors.secondary, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = true) + content( + buildAnnotatedString { withStyle(chatEventStyle) { withStyle(reserveTimestampStyle) { append("\u00A0\u00A0\u00A0") } } }, + buildAnnotatedString { withStyle(reserveTimestampStyle) { append(copy) } } + ) + } + } } @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index 27e605df4f..0ae7860314 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -21,33 +21,36 @@ fun CIFeaturePreferenceView( allowed: FeatureAllowed, acceptFeature: (Contact, ChatFeature, Int?) -> Unit ) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary) - if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) { - val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) - val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null - val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature - val param = if (setParam) 86400 else null - val annotatedText = buildAnnotatedString { - withStyle(chatEventStyle) { append(chatItem.content.text + " ") } - withAnnotation(tag = "Accept", annotation = "Accept") { - withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") } + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(feature.icon, feature.text, Modifier.inlineIcon().size(18.dp), tint = MaterialTheme.colors.secondary) + if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) { + val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) + val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null + val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature + val param = if (setParam) 86400 else null + val annotatedText = buildAnnotatedString { + withStyle(chatEventStyle) { append(chatItem.content.text + " ") } + withAnnotation(tag = "Accept", annotation = "Accept") { + withStyle(acceptStyle) { append(generalGetString(acceptTextId)); append(separator) } + } + withStyle(chatEventStyle) { append(ts) } } - withStyle(chatEventStyle) { append(chatItem.timestampText) } + fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty() + ClickableText( + annotatedText, + style = chatEventTextStyle(TextStyle.Default), + onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } }, + shouldConsumeEvent = ::accept + ) + } else { + Text(AnnotatedString(chatItem.content.text) + separator + ts, + fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary, style = chatEventTextStyle()) } - fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty() - ClickableText( - annotatedText, - onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } }, - shouldConsumeEvent = ::accept - ) - } else { - Text(chatItem.content.text + " " + chatItem.timestampText, - fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 42cbff3a2f..f857d6a190 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -43,6 +43,7 @@ fun CIFileView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) val sizeMultiplier = 1f val progressSizeMultiplier = if (smallView) 0.7f else 1f + val terminal = LocalTerminalLayout.current @Composable fun fileIcon( innerIcon: Painter? = null, @@ -133,7 +134,7 @@ fun CIFileView( fun fileIndicator() { Box( Modifier - .padding(top = 2.sp.toDp()) + .padding(top = if (terminal) 0.dp else 2.sp.toDp()) .size(40.sp.toDp() * sizeMultiplier) .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center @@ -205,7 +206,13 @@ fun CIFileView( onClick = onClick, onLongClick = { showMenu.value = true } ) - .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), + .padding( + when { + smallView -> PaddingValues() + terminal -> PaddingValues(top = TERMINAL_FILE_TOP_GAP.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp()) + else -> PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp()) + } + ), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 14ecd723b9..38eaa6f170 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -85,71 +85,77 @@ fun CIGroupInvitationView( val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { - inProgress.value = true - joinGroup(groupInvitation.groupId) { inProgress.value = false } - }) else Modifier, - shape = RoundedCornerShape(18.dp), - color = if (sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier - .width(IntrinsicSize.Min) - .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), - contentAlignment = Alignment.BottomEnd + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } + }) else Modifier, + shape = RoundedCornerShape(18.dp), + color = if (sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { Box( - contentAlignment = Alignment.Center + Modifier + .width(IntrinsicSize.Min) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - val secondaryColor = MaterialTheme.colors.secondary - Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) - Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (groupChat) { - Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) - } - if (action) { - Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) - Text( - buildAnnotatedString { - append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - }, - color = if (inProgress.value) - MaterialTheme.colors.secondary - else - if (chatIncognito) Indigo else MaterialTheme.colors.primary - ) - } else { - Text( - buildAnnotatedString { - append(groupInvitationStr()) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - }, - fontSize = 13.sp, - lineHeight = 18.sp, - ) + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + val secondaryColor = MaterialTheme.colors.secondary + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } + if (action) { + Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) + Text( + buildAnnotatedString { + append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } + } + }, + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Text( + buildAnnotatedString { + append(groupInvitationStr()) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } + } + }, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } - if (progressByTimeout) { - CircularProgressIndicator( - Modifier.size(32.dp), - color = if (isInDarkTheme()) FileDark else FileLight, - strokeWidth = 3.dp - ) - } + inlineMeta?.invoke() } - - CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt index bbd7788f29..3eba43c685 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt @@ -32,39 +32,42 @@ fun CIMemberCreatedContactView( } } - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (chatItem.chatDir is CIDirection.GroupRcv && chatItem.chatDir.groupMember.memberContactId != null) { - val openChatStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) - val annotatedText = buildAnnotatedString { - append(eventText()) - append(" ") - withAnnotation(tag = "Open", annotation = "Open") { - withStyle(openChatStyle) { append(generalGetString(MR.strings.rcv_group_event_open_chat) + " ") } - } - withStyle(chatEventStyle) { append(chatItem.timestampText) } - } - - fun open(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Open", start = offset, end = offset).isNotEmpty() - ClickableText( - annotatedText, - onClick = { - if (open(it)) { - openDirectChat(chatItem.chatDir.groupMember.memberContactId) + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (chatItem.chatDir is CIDirection.GroupRcv && chatItem.chatDir.groupMember.memberContactId != null) { + val openChatStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) + val annotatedText = buildAnnotatedString { + append(eventText()) + append(" ") + withAnnotation(tag = "Open", annotation = "Open") { + withStyle(openChatStyle) { append(generalGetString(MR.strings.rcv_group_event_open_chat)); append(separator) } } - }, - shouldConsumeEvent = ::open - ) - } else { - val annotatedText = buildAnnotatedString { - append(eventText()) - append(" ") - withStyle(chatEventStyle) { append(chatItem.timestampText) } + withStyle(chatEventStyle) { append(ts) } + } + + fun open(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Open", start = offset, end = offset).isNotEmpty() + ClickableText( + annotatedText, + style = chatEventTextStyle(TextStyle.Default), + onClick = { + if (open(it)) { + openDirectChat(chatItem.chatDir.groupMember.memberContactId) + } + }, + shouldConsumeEvent = ::open + ) + } else { + val annotatedText = buildAnnotatedString { + append(eventText()) + append(separator) + withStyle(chatEventStyle) { append(ts) } + } + Text(annotatedText, style = chatEventTextStyle()) } - Text(annotatedText) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index e318f406de..ba22ae5966 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -8,8 +8,19 @@ 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.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -38,14 +49,17 @@ fun CIMetaView( showEdited: Boolean = true, showTimestamp: Boolean, showViaProxy: Boolean, + lineHeightColor: Color = metaColor, ) { + val timeStyle = LocalTextStyle.current.merge(terminalTextStyle(12.sp, lineHeightColor)) Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) { if (chatItem.isDeletedContent) { Text( chatItem.timestampText, color = metaColor, fontSize = 12.sp, - modifier = Modifier.padding(start = 3.dp) + modifier = Modifier.padding(start = 3.dp), + style = timeStyle ) } else { CIMetaText( @@ -54,6 +68,7 @@ fun CIMetaView( encrypted = chatItem.encryptedFile, metaColor, paleMetaColor, + timeStyle, showStatus = showStatus, showEdited = showEdited, showViaProxy = showViaProxy, @@ -72,6 +87,7 @@ private fun CIMetaText( encrypted: Boolean?, color: Color, paleColor: Color, + timeStyle: TextStyle, showStatus: Boolean = true, showEdited: Boolean = true, showTimestamp: Boolean, @@ -87,7 +103,7 @@ private fun CIMetaText( StatusIconText(painterResource(MR.images.ic_timer), color) val ttl = meta.itemTimed?.ttl if (ttl != chatTTL) { - Text(shortTimeText(ttl), color = color, fontSize = 12.sp) + Text(shortTimeText(ttl), color = color, fontSize = 12.sp, style = timeStyle) } } if (showViaProxy && meta.sentViaProxy == true) { @@ -99,7 +115,7 @@ private fun CIMetaText( val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor) if (statusIcon != null) { val (icon, statusColor) = statusIcon - if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) { + if (meta.tallStatusIcon) { Icon(painterResource(icon), null, Modifier.height(17.dp), tint = statusColor) } else { StatusIconText(painterResource(icon), statusColor) @@ -122,11 +138,10 @@ private fun CIMetaText( if (showTimestamp) { Spacer(Modifier.width(4.dp)) - Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = timeStyle) } } -// the conditions in this function should match CIMetaText fun reserveSpaceForMeta( meta: CIMeta, chatTTL: Int?, @@ -137,63 +152,126 @@ fun reserveSpaceForMeta( showViaProxy: Boolean = false, showTimestamp: Boolean, signedFileVerified: Boolean? = null -): String { +): String = buildString { + reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, signedFileVerified, tallIconSpace = "", spaces = { append(it) }, text = { append(it) }) +} + +// the conditions in this function should match CIMetaText +private fun reserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + secondaryColor: Color, + showStatus: Boolean, + showEdited: Boolean, + showViaProxy: Boolean, + showTimestamp: Boolean, + signedFileVerified: Boolean?, + tallIconSpace: String, + spaces: (String) -> Unit, + text: (String) -> Unit +) { val showSignature = appPreferences.privacyShowSignature.state.value val showEncryption = appPreferences.privacyShowEncryption.state.value val iconSpace = " \u00A0\u00A0\u00A0" val whiteSpace = "\u00A0" - var res = if (showTimestamp) "" else iconSpace + if (!showTimestamp) spaces(iconSpace) var space: String? = null fun appendSpace() { - if (space != null) { - res += space - space = null - } + space?.let(spaces) + space = null } if (showEdited && meta.itemEdited) { - res += iconSpace + spaces(iconSpace) } if (meta.itemTimed != null) { - res += iconSpace + spaces(iconSpace) val ttl = meta.itemTimed.ttl if (ttl != chatTTL) { - res += shortTimeText(ttl) + text(shortTimeText(ttl)) } space = whiteSpace } if (showViaProxy && meta.sentViaProxy == true) { appendSpace() - res += iconSpace + spaces(iconSpace + tallIconSpace) } if (showStatus) { appendSpace() if (meta.statusIcon(secondaryColor) != null) { - res += iconSpace + spaces(if (meta.tallStatusIcon) iconSpace + tallIconSpace else iconSpace) } else if (!meta.disappearing) { - res += iconSpace + spaces(iconSpace) } space = whiteSpace } if (encrypted != null && showEncryption) { appendSpace() - res += iconSpace + spaces(iconSpace) space = whiteSpace } if ((showSignature && meta.msgVerified?.verified == true && signedFileVerified != false) || meta.msgVerified is MsgVerified.SigMissing) { appendSpace() - res += iconSpace + spaces(iconSpace) space = whiteSpace } if (showTimestamp) { appendSpace() - res += meta.timestampText + text(meta.timestampText) } - return res } +@Composable +fun terminalReserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + signedFileVerified: Boolean?, + secondaryColor: Color, + showStatus: Boolean = true, + showEdited: Boolean = true, + showViaProxy: Boolean, + showTimestamp: Boolean +): AnnotatedString = + terminalReserveSpaceForMeta( + meta, chatTTL, encrypted, signedFileVerified, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, + density = LocalDensity.current, + bodyFontSize = MaterialTheme.typography.body1.fontSize, + direction = LocalLayoutDirection.current + ) + +fun terminalReserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + signedFileVerified: Boolean?, + secondaryColor: Color, + showStatus: Boolean, + showEdited: Boolean, + showViaProxy: Boolean, + showTimestamp: Boolean, + density: Density, + bodyFontSize: TextUnit, + direction: LayoutDirection +): AnnotatedString { + val spaceStyle = SpanStyle(fontSize = with(density) { bodyFontSize.value.dp.toSp() }, fontWeight = FontWeight.Normal) + val textStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Normal) + return buildAnnotatedString { + append(if (direction == LayoutDirection.Ltr) "\u2060\u200E" else "\u2060\u200F") + reserveSpaceForMeta( + meta, chatTTL, encrypted, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, signedFileVerified, tallIconSpace = "\u00A0", + spaces = { withStyle(spaceStyle) { append(it.replace(' ', '\u00A0')) } }, + text = { withStyle(textStyle) { append(it.replace(' ', '\u00A0')) } } + ) + } +} + +private val CIMeta.tallStatusIcon: Boolean get() = + itemStatus is CIStatus.SndSent || itemStatus is CIStatus.SndRcvd + @Composable private fun StatusIconText(icon: Painter, color: Color) { Icon(icon, null, Modifier.height(12.dp), tint = color) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 589b2a6fa6..42a8720018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -139,44 +139,48 @@ fun DecryptionErrorItemFixButton( syncSupported: Boolean ) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.BottomEnd, + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) - ) - Row { - Icon( - painterResource(MR.images.ic_sync_problem), - stringResource(MR.strings.fix_connection), - tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - Spacer(Modifier.padding(2.dp)) - val secondaryColor = MaterialTheme.colors.secondary + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { Text( buildAnnotatedString { - append(generalGetString(MR.strings.fix_connection)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } - withStyle(reserveTimestampStyle) { append(" ") } // for icon + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } }, - color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) ) + Row { + Icon( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(2.dp)) + val secondaryColor = MaterialTheme.colors.secondary + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.fix_connection)) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } + withStyle(reserveTimestampStyle) { append(" ") } // for icon + } + }, + color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } } + inlineMeta?.invoke() } - CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) } } } @@ -187,25 +191,29 @@ fun DecryptionErrorItem( onClick: () -> Unit ) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.BottomEnd, + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - val secondaryColor = MaterialTheme.colors.secondary - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) - ) - CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + val secondaryColor = MaterialTheme.colors.secondary + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } + } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 5aeefd9668..aa49808fe8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -44,10 +44,11 @@ fun CIVoiceView( receiveFile: (Long) -> Unit, ) { val sizeMultiplier = if (smallView) voiceMessageSizeBasedOnSquareSize(36f) / 56f else 1f + val classicWithText = hasText && !LocalTerminalLayout.current val padding = when { smallView -> PaddingValues() - hasText -> PaddingValues(top = 14.sp.toDp() * sizeMultiplier, bottom = 14.sp.toDp() * sizeMultiplier, start = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier) - else -> PaddingValues(top = 4.sp.toDp() * sizeMultiplier, bottom = 6.sp.toDp() * sizeMultiplier, start = 0.dp, end = 0.dp) + classicWithText -> PaddingValues(top = 14.sp.toDp() * sizeMultiplier, bottom = 14.sp.toDp() * sizeMultiplier, start = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier) + else -> PaddingValues(top = 4.sp.toDp() * sizeMultiplier, bottom = 6.sp.toDp() * sizeMultiplier, start = if (LocalTerminalLayout.current) CHAT_ITEM_TEXT_PADDING else 0.dp, end = 0.dp) } Row( Modifier.padding(padding), @@ -87,7 +88,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, classicWithText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -120,6 +121,7 @@ private fun VoiceLayout( brokenAudio: Boolean, sent: Boolean, hasText: Boolean, + classicWithText: Boolean, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean, @@ -182,7 +184,7 @@ private fun VoiceLayout( } } } - hasText -> { + classicWithText -> { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Spacer(Modifier.width(6.sp.toDp() * sizeMultiplier)) @@ -192,7 +194,7 @@ private fun VoiceLayout( Slider(if (ci.chatDir.sent) sentColor else receivedColor) } } - sent -> { + sent && !LocalTerminalLayout.current -> { Column(horizontalAlignment = Alignment.End) { Row { Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { @@ -208,19 +210,25 @@ private fun VoiceLayout( } } else -> { - Column(horizontalAlignment = Alignment.Start) { - Row { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) - Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) - Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) - Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) + @Composable + fun VoiceRow(inlineMeta: (@Composable () -> Unit)?) { + Column(horizontalAlignment = Alignment.Start) { + Row { + VoiceMsgIndicator(file, audioPlaying.value, sent, false, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) + Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) + Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) + } + } + if (inlineMeta != null) { + Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) { + inlineMeta() + } } } - Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) { - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } } + if (hasText) VoiceRow(null) else ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { VoiceRow(it) } } } } 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 822aef9aa2..4305a35502 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 @@ -50,12 +50,12 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary) -fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString = - chatEventText(ci.content.text(isChannel), ci.timestampText) +fun chatEventText(ci: ChatItem, isChannel: Boolean = false, ts: AnnotatedString): AnnotatedString = + chatEventText(ci.content.text(isChannel), ts) -fun chatEventText(eventText: String, ts: String): AnnotatedString = +fun chatEventText(eventText: String, ts: AnnotatedString): AnnotatedString = buildAnnotatedString { - withStyle(chatEventStyle) { append("$eventText $ts") } + withStyle(chatEventStyle) { append(eventText); append(ts) } } data class ChatItemReactionMenuItem ( @@ -64,6 +64,9 @@ data class ChatItemReactionMenuItem ( val onClick: (() -> Unit)? ) +private val REACTION_SPACING = 5.dp +private val REACTION_PADDING = 2.dp + // Spec: spec/client/chat-view.md#ChatItemView @Composable fun ChatItemView( @@ -113,11 +116,14 @@ fun ChatItemView( itemSeparation: ItemSeparation, preview: Boolean = false, swipeOffset: Float = 0f, + bubbleInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent - val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart + val terminal = LocalTerminalLayout.current + val alignEnd = sent && !terminal + val alignment = if (alignEnd) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } @@ -139,7 +145,7 @@ fun ChatItemView( @Composable fun ChatItemReactions() { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true).padding(start = if (terminal) CHAT_ITEM_TEXT_PADDING - REACTION_SPACING - REACTION_PADDING else 0.dp)) { cItem.reactions.forEach { r -> val showReactionMenu = remember { mutableStateOf(false) } val reactionMenuItems = remember { mutableStateOf(emptyList()) } @@ -153,7 +159,7 @@ fun ChatItemView( } } - var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) + var modifier = Modifier.padding(horizontal = REACTION_SPACING, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) if (cInfo.featureEnabled(ChatFeature.Reactions)) { fun showReactionsMenu() { when (cInfo) { @@ -206,7 +212,7 @@ fun ChatItemView( ) .onRightClick { showReactionsMenu() } } - Row(modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) { + Row(modifier.padding(REACTION_PADDING), verticalAlignment = Alignment.CenterVertically) { ReactionIcon(r.reaction.text, fontSize = 12.sp) DefaultDropdownMenu(showMenu = showReactionMenu) { reactionMenuItems.value.forEach { m -> @@ -301,20 +307,20 @@ fun ChatItemView( } } - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Column(horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start) { val canReply = (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && cInfo !is ChatInfo.Local && !cItem.isReport && !cItem.meta.isLive && cItem.meta.itemDeleted == null Box { Row(verticalAlignment = Alignment.CenterVertically) { - val bubbleInteractionSource = remember { MutableInteractionSource() } val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() - if (cItem.chatDir.sent) { + val markedDeleted = cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent) + if (alignEnd) { 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) { + if ((highlighted.value && !terminal) || hoveredItemId.value == cItem.id) { bubbleInteractionSource.emit(enterInteraction) } else { bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) @@ -322,7 +328,7 @@ fun ChatItemView( } Column( Modifier - .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) + .then(if (terminal) Modifier else Modifier.clipChatItem(cItem, itemSeparation.largeGap, revealed.value)) .hoverable(bubbleInteractionSource) .combinedClickable( onLongClick = { showMenu.value = true }, @@ -331,12 +337,12 @@ fun ChatItemView( hoveredItemId.value = if (hoveredItemId.value == cItem.id) null else cItem.id } onClick() - }, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + }, interactionSource = bubbleInteractionSource, indication = if (terminal) null else LocalIndication.current) .onRightClick { showMenu.value = true }, ) { @Composable fun framedItemView() { - FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) + FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem, endButton = if (terminal && !searchIsNotBlank.value && cItem.meta.itemForwarded?.chatTypeApiIdMsgId != null) { { GoToItemButton(false, bubbleHovered) } } else null) } fun deleteMessageQuestionText(): String { @@ -571,7 +577,7 @@ fun ChatItemView( @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (!cItem.hasQuoteOrHeader) { 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()) { @@ -622,24 +628,26 @@ fun ChatItemView( } } - fun eventItemViewText(reversedChatItems: List): AnnotatedString { + fun eventItemViewText(reversedChatItems: List, ts: AnnotatedString): AnnotatedString { val memberDisplayName = cItem.memberDisplayName val t = mergedGroupEventText(cItem, reversedChatItems) return if (!revealed.value && t != null) { - chatEventText(t, cItem.timestampText) + chatEventText(t, ts) } else if (memberDisplayName != null) { buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") - }.plus(chatEventText(cItem, cInfo.isChannel)) + }.plus(chatEventText(cItem, cInfo.isChannel, ts)) } else { - chatEventText(cItem, cInfo.isChannel) + chatEventText(cItem, cInfo.isChannel, ts) } } @Composable fun EventItemView() { val reversedChatItems = chatsCtx.chatItems.value.asReversed() - CIEventView(eventItemViewText(reversedChatItems)) + ChatEventWithMeta(cItem) { separator, ts -> + CIEventView(eventItemViewText(reversedChatItems, separator + ts)) + } } @Composable fun PendingReviewEventItemView() { @@ -647,7 +655,8 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) } }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + Modifier.padding(chatEventPadding()), + style = chatEventTextStyle() ) } @@ -677,7 +686,8 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle) { append(annotatedStringResource(sId)) } }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + Modifier.padding(chatEventPadding()), + style = chatEventTextStyle() ) } @@ -695,7 +705,7 @@ fun ChatItemView( e2eeInfoText(if (e2EEInfo.public == true) MR.strings.e2ee_info_no_e2ee else MR.strings.e2ee_info_no_pq) } - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + if (markedDeleted) { MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { @@ -803,7 +813,7 @@ fun ChatItemView( } } } - if (!cItem.chatDir.sent) { + if (!alignEnd && (!terminal || searchIsNotBlank.value || markedDeleted)) { GoToItemButton(false, bubbleHovered) } } @@ -823,6 +833,9 @@ fun ChatItemView( } } +val ChatItem.hasQuoteOrHeader: Boolean get() = + quotedItem != null || meta.itemForwarded != null || meta.itemDeleted != null || meta.isLive || isReport + @Composable expect fun ReactionIcon(text: String, fontSize: TextUnit = TextUnit.Unspecified) @@ -1203,10 +1216,12 @@ fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Un } } +@Composable +fun chatItemTailEnabled(): Boolean = remember { appPreferences.chatItemTail.state }.value && !LocalTerminalLayout.current + @Composable fun Modifier.chatItemOffset(cItem: ChatItem, tailVisible: Boolean, inverted: Boolean = false, revealed: Boolean): Modifier { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, tailVisible, revealed) + val style = shapeStyle(cItem, chatItemTailEnabled(), tailVisible, revealed) val offset = if (style is ShapeStyle.Bubble) { if (style.tailVisible) { @@ -1222,8 +1237,7 @@ fun Modifier.chatItemOffset(cItem: ChatItem, tailVisible: Boolean, inverted: Boo @Composable fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = false, revealed: Boolean = false): Modifier { val chatItemRoundness = remember { appPreferences.chatItemRoundness.state } - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(chatItem, chatItemTail.value, tailVisible, revealed) + val style = shapeStyle(chatItem, chatItemTailEnabled(), tailVisible, revealed) val cornerRoundness = chatItemRoundness.value.coerceIn(0f, 1f) return when (style) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt index 17245c4e75..70bf88a51c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt @@ -20,23 +20,25 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, val sent = ci.chatDir.sent val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - shape = RoundedCornerShape(18.dp), - color = if (sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.Bottom + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + shape = RoundedCornerShape(18.dp), + color = if (sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - modifier = Modifier.padding(end = 8.dp) - ) - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = if (inlineMeta != null) 8.dp else 0.dp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 3bcd02411f..4b8633c4b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -15,6 +15,7 @@ import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.toDp import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -25,18 +26,20 @@ fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Bool val emojiText = chatItem.content.text.trim() val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) - Column( - Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (isSelected) { - Box(Modifier.background(SelectionHighlightColor)) { + ItemWithMeta(meta = { CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }, metaReserved = true) { inlineMeta -> + Column( + if (LocalTerminalLayout.current) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EMOJI_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EMOJI_BOTTOM_GAP.toDp()).messageLastLine() else Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { EmojiText(chatItem.content.text) } - } else { - EmojiText(chatItem.content.text) + inlineMeta?.invoke() } - CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } 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 ca370c1b52..acae6c4c44 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 @@ -47,10 +47,17 @@ fun FramedItemView( scrollToItem: (Long) -> Unit = {}, scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit = {}, + endButton: (@Composable () -> Unit)? = null, ) { val chatInfo = chat.chatInfo val sent = ci.chatDir.sent val chatTTL = chatInfo.timedMessagesTTL + val terminal = LocalTerminalLayout.current + val mediaModifier = if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_BLOCK_TOP_GAP.toDp()).clipChatItem(ci, revealed = true) else Modifier + val quoteColor = if (sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote + val cardModifier = mediaModifier.background(quoteColor) + val thumbnailClip = if (terminal) Modifier.clipChatItem(ci, revealed = true) else Modifier + val quoteBaseStyle = if (terminal) MaterialTheme.typography.body1 else TextStyle() fun membership(): GroupMember? { return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null @@ -58,6 +65,7 @@ fun FramedItemView( @Composable fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null) { + val color = if (terminal) MaterialTheme.colors.onSurface.copy(alpha = 0.7f) else MaterialTheme.colors.onSurface MarkdownText( qi.text, qi.formattedText, @@ -65,7 +73,7 @@ fun FramedItemView( toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), + style = quoteBaseStyle.copy(fontSize = 15.sp, color = color).merge(terminalTextStyle(15.sp, color)), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, showTimestamp = showTimestamp, @@ -81,7 +89,7 @@ fun FramedItemView( // 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), + .then(if (terminal) Modifier.padding(end = 12.dp) else Modifier.padding(vertical = 6.dp, horizontal = 12.dp)), contentAlignment = Alignment.TopStart ) { val sender = qi.sender(membership()) @@ -91,7 +99,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), + style = quoteBaseStyle.copy(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary).merge(terminalTextStyle(13.5.sp)), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) @@ -111,7 +119,7 @@ fun FramedItemView( append(caption) } }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp).merge(terminalTextStyle(fontSize, MaterialTheme.colors.secondary)), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -119,13 +127,14 @@ fun FramedItemView( @Composable fun headerModifier(pad: Boolean, onClick: (() -> Unit)? = null): Modifier { - val sentColor = MaterialTheme.appColors.sentQuote - val receivedColor = MaterialTheme.appColors.receivedQuote return Modifier - .background(if (sent) sentColor else receivedColor) + .then(if (terminal) Modifier else Modifier.background(quoteColor)) .fillMaxWidth() .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + .then( + if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_HEADER_TOP_GAP.toDp(), end = 12.dp, bottom = TERMINAL_HEADER_BOTTOM_GAP.toDp()) + else Modifier.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + ) } @Composable @@ -139,7 +148,7 @@ fun FramedItemView( Icon( icon, caption, - Modifier.size(18.dp), + Modifier.inlineIcon().size(18.dp), tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } @@ -155,11 +164,9 @@ fun FramedItemView( @Composable fun ciQuoteView(qi: CIQuote) { val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) } - val sentColor = MaterialTheme.appColors.sentQuote - val receivedColor = MaterialTheme.appColors.receivedQuote Row( Modifier - .background(if (sent) sentColor else receivedColor) + .then(if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_BLOCK_TOP_GAP.toDp(), bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()).quoteBar(MaterialTheme.colors.onSurface) else Modifier.background(quoteColor)) .fillMaxWidth() ) { when (qi.content) { @@ -172,7 +179,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) + modifier = Modifier.size(68.dp).then(thumbnailClip).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCVideo -> { @@ -184,7 +191,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) + modifier = Modifier.size(68.dp).then(thumbnailClip).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCFile, is MsgContent.MCVoice -> { @@ -195,7 +202,7 @@ fun FramedItemView( if (qi.content is MsgContent.MCFile) painterResource(MR.images.ic_draft_filled) else painterResource(MR.images.ic_mic_filled), if (qi.content is MsgContent.MCFile) stringResource(MR.strings.icon_descr_file) else stringResource(MR.strings.voice_message), Modifier - .padding(top = 6.dp, end = 4.dp) + .padding(top = if (terminal) 0.dp else 6.dp, end = 4.dp) .size(22.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) @@ -210,7 +217,7 @@ fun FramedItemView( Icon( painterResource(qi.content.chatLink.smallIconRes), null, - Modifier.padding(top = 6.dp, end = 4.dp).size(22.dp), + Modifier.padding(top = if (terminal) 0.dp else 6.dp, end = 4.dp).size(22.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) } @@ -223,220 +230,248 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) } } - val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && - !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null + val captionlessMedia = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && + !ci.meta.isLive && ci.content.text.isEmpty() + val transparentBackground = captionlessMedia && ci.quotedItem == null && ci.meta.itemForwarded == null val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage + val (metaReserved, gapBelowBlock) = when (val mc = ci.content.msgContent) { + is MsgContent.MCVoice -> (mc.text != "") to false + is MsgContent.MCChat -> (mc.text != mc.chatLink.connLinkStr).let { it to !it } + else -> !captionlessMedia to captionlessMedia + } Box(Modifier - .clipChatItem(ci, tailVisible, revealed = true) + .then( + when { + !terminal -> Modifier.clipChatItem(ci, tailVisible, revealed = true) + gapBelowBlock -> Modifier.padding(bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + else -> Modifier + } + ) .background( when { - transparentBackground -> Color.Transparent + transparentBackground || terminal -> Color.Transparent sent -> sentColor else -> receivedColor } )) { var metaColor = MaterialTheme.colors.secondary - Box(contentAlignment = Alignment.BottomEnd) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(ci, chatItemTail.value, tailVisible, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - Column( - Modifier - .width(IntrinsicSize.Max) - .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) - ) { - PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - @Composable - fun Header() { - if (ci.isReport) { - if (ci.meta.itemDeleted == null) { - FramedItemHeader( - stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), - true, - painterResource(MR.images.ic_flag), - iconColor = Color.Red - ) - } else { - val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { - stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + ItemWithMeta( + meta = { CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }, + overlayMeta = if (captionlessMedia) { { CIMetaView(ci, chatTTL, Color.White, showViaProxy = showViaProxy, showTimestamp = showTimestamp, lineHeightColor = messageTextColor()) } } else null, + metaReserved = metaReserved, + endButton = endButton + ) { inlineMeta -> + Box(contentAlignment = Alignment.BottomEnd) { + val style = shapeStyle(ci, chatItemTailEnabled(), tailVisible, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Column( + Modifier + .width(IntrinsicSize.Max) + .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) + ) { + PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth = !terminal || captionlessMedia) { + @Composable + fun Header() { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) } else { - stringResource(MR.strings.report_item_archived) - } - FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) - } - } else if (ci.meta.itemDeleted != null) { - when (ci.meta.itemDeleted) { - is CIDeleted.Moderated -> { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } - is CIDeleted.Blocked -> { - FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.BlockedByAdmin -> { - FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.Deleted -> { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) - } - } - } else if (ci.meta.isLive) { - FramedItemHeader(stringResource(MR.strings.live), false) - } - } - if (ci.quotedItem != null) { - Column( - Modifier - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { - if (ci.quotedItem.itemId != null) { - if (ci.isReport && chatsCtx.secondaryContextFilter != null) { - scrollToItemId.value = ci.quotedItem.itemId - } else { - scrollToItem(ci.quotedItem.itemId) - } - } else { - scrollToQuotedItemFromItem(ci.id) - } + val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + } else { + stringResource(MR.strings.report_item_archived) } - ) - .onRightClick { showMenu.value = true } - ) { - Header() - ciQuoteView(ci.quotedItem) - } - } else { - Header() - val forwarded = ci.meta.itemForwarded - if (forwarded != null) { - val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { - forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null - } else { - when (forwarded) { - is CIForwardedFrom.Group -> forwarded.groupType != null - is CIForwardedFrom.GroupLink -> true - else -> false + FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) } - } - if (twoRowHeader) { - val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) - Column( - headerModifier(pad = true, onClick = { - val target = forwarded.chatTypeApiIdMsgId - val link = forwarded.sourceGroupLink - if (target != null) { - val (chatType, apiId, itemId) = target - withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } - } else if (link != null) { - withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } - } - }), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) - HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } else if (ci.meta.itemDeleted != null) { + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.BlockedByAdmin -> { + FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.Deleted -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } - } else { - FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(MR.strings.live), false) } } - } - if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + if (ci.quotedItem != null) { Column( Modifier - .padding(bottom = 2.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - EmojiText(ci.content.text) - Text( - reserveSpaceForMeta(ci.meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp), - color = Color.Transparent, - style = MaterialTheme.typography.body1 - ) - } - } - } else { - when (val mc = ci.content.msgContent) { - is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) - if (mc.text == "" && !ci.meta.isLive) { - metaColor = Color.White - } else { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) - if (mc.text == "" && !ci.meta.isLive) { - metaColor = Color.White - } else { - CIMarkdownText(chatsCtx, ci, chat, 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(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCFile -> ciFileView(ci, mc.text) - is MsgContent.MCUnknown -> - if (ci.file == null) { - CIMarkdownText(chatsCtx, ci, chat, 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(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCChat -> { - val hasText = mc.text != mc.chatLink.connLinkStr - Box( - Modifier.combinedClickable( + .combinedClickable( + onLongClick = { showMenu.value = true }, onClick = { - withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } - }, - onLongClick = { showMenu.value = true } + if (ci.quotedItem.itemId != null) { + if (ci.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = ci.quotedItem.itemId + } else { + scrollToItem(ci.quotedItem.itemId) + } + } else { + scrollToQuotedItemFromItem(ci.id) + } + } ) - ) { - CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) - } - if (hasText) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr) - } + .onRightClick { showMenu.value = true } + ) { + Header() + ciQuoteView(ci.quotedItem) } - is MsgContent.MCReport -> { - val prefix = buildAnnotatedString { - withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { - append(itemPrefixText(ci)) + } else { + Header() + val forwarded = ci.meta.itemForwarded + if (forwarded != null) { + val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { + forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null + } else { + when (forwarded) { + is CIForwardedFrom.Group -> forwarded.groupType != null + is CIForwardedFrom.GroupLink -> true + else -> false } } - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + if (twoRowHeader) { + val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) + Column( + headerModifier(pad = true, onClick = { + val target = forwarded.chatTypeApiIdMsgId + val link = forwarded.sourceGroupLink + if (target != null) { + val (chatType, apiId, itemId) = target + withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } + } else if (link != null) { + withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } + } + }), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) + HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } + } else { + FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } + } + } + if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { + Box(if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EMOJI_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EMOJI_BOTTOM_GAP.toDp()) else Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + Column( + if (terminal) Modifier.messageLastLine() else Modifier.padding(bottom = 2.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmojiText(ci.content.text) + if (!terminal) { + Text( + reserveSpaceForMeta(ci.meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp), + color = Color.Transparent, + style = MaterialTheme.typography.body1 + ) + } + } + } + } else { + when (val mc = ci.content.msgContent) { + is MsgContent.MCImage -> { + val provider = imageProvider ?: return@PriorityLayout + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(mediaModifier)) { + CIImageView(image = mc.image, file = ci.file, provider, showMenu, false, receiveFile) + } + if (mc.text == "" && !ci.meta.isLive) { + if (!terminal) metaColor = Color.White + } else { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCVideo -> { + val provider = imageProvider ?: return@PriorityLayout + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(mediaModifier)) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, showMenu, smallView = false, receiveFile = receiveFile) + } + if (mc.text == "" && !ci.meta.isLive) { + if (!terminal) metaColor = Color.White + } else { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + 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(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) + } + } + is MsgContent.MCFile -> ciFileView(ci, mc.text) + is MsgContent.MCUnknown -> + if (ci.file == null) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) + } else { + ciFileView(ci, mc.text) + } + is MsgContent.MCLink -> { + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(if (terminal) cardModifier.padding(bottom = 8.dp) else Modifier)) { + ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) + } + Box(if (terminal) Modifier else Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + Box( + Modifier.then(if (terminal) cardModifier.width(IntrinsicSize.Max) else Modifier).combinedClickable( + onClick = { + withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } + }, + onLongClick = { showMenu.value = true } + ) + ) { + CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + } + if (hasText) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(itemPrefixText(ci)) + } + } + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, terminalTop = 0.dp) + } + else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) } - else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } - } - Box( - Modifier - .padding( - bottom = 6.dp, - end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp, - ) - ) { - CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + if (inlineMeta != null) { + Box( + Modifier + .padding( + bottom = 6.dp, + end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp, + ) + ) { + inlineMeta() + } + } } } } @@ -454,18 +489,29 @@ fun CIMarkdownText( showViaProxy: Boolean, showTimestamp: Boolean, prefix: AnnotatedString? = null, - stripLink: String? = null + stripLink: String? = null, + terminalTop: Dp ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) + val terminal = LocalTerminalLayout.current - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { + Box( + Modifier + .padding( + start = CHAT_ITEM_TEXT_PADDING, + top = if (terminal) terminalTop + terminalHeadingGap(ci).toDp() else CHAT_ITEM_TEXT_VERTICAL_PADDING, + end = CHAT_ITEM_TEXT_PADDING, + bottom = if (terminal) 0.dp else CHAT_ITEM_TEXT_VERTICAL_PADDING + ) + ) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, - meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + meta = ci.meta, chatTTL = chatTTL, encrypted = ci.encryptedFile, signedFileVerified = ci.file?.loaded, linkMode = linkMode, + style = MaterialTheme.typography.body1.copy(color = messageTextColor(), lineHeight = 22.sp).merge(terminalTextStyle(MaterialTheme.typography.body1.fontSize)), mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -494,7 +540,7 @@ fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) { if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } } @@ -509,6 +555,8 @@ const val CHAT_IMAGE_LAYOUT_ID = "chatImage" const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble" const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose" const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose" +val CHAT_ITEM_TEXT_PADDING = 12.dp +val CHAT_ITEM_TEXT_VERTICAL_PADDING = 7.dp /** * Compose shows "Can't represent a width of ... and height ... in Constraints" even when using built-in method for measuring max @@ -524,6 +572,7 @@ private fun horizontalPaddingAroundCustomLayouts(density: Float): Int = fun PriorityLayout( modifier: Modifier = Modifier, priorityLayoutId: String, + limitToPriorityWidth: Boolean = true, content: @Composable () -> Unit ) { Layout( @@ -540,9 +589,9 @@ fun PriorityLayout( if (it.layoutId == priorityLayoutId) imagePlaceable!! else - it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) } + it.measure(constraints.copy(maxWidth = if (limitToPriorityWidth) imagePlaceable?.width ?: constraints.maxWidth else constraints.maxWidth)) } // Limit width for every other element to width of important element and height for a sum of all elements. - val width = imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width } + val width = if (limitToPriorityWidth) imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width } else placeables.maxOf { it.width } val height = placeables.sumOf { it.height } val adjustedConstraints = Constraints.fitPrioritizingHeight(constraints.minWidth, width, constraints.minHeight, height) layout( @@ -551,7 +600,7 @@ fun PriorityLayout( ) { var y = 0 placeables.forEach { - it.place(0, y) + if (limitToPriorityWidth) it.place(0, y) else it.placeRelative(0, y) y += it.measuredHeight } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 9b378ea9ea..2869ce4168 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -52,24 +52,26 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: @Composable fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onClick: () -> Unit) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.Bottom + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - modifier = Modifier.padding(end = 8.dp) - ) - CIMetaView(ci, timedMessagesTTL, showViaProxy = false, showTimestamp = showTimestamp) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = if (inlineMeta != null) 8.dp else 0.dp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt index 75a778e567..637b53a23b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -21,13 +21,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString @@ -35,6 +40,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.takeOrElse @@ -44,6 +52,8 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlin.math.pow +import kotlin.math.roundToInt @Composable fun MarkdownBlocks( @@ -53,7 +63,8 @@ fun MarkdownBlocks( modifier: Modifier, cardColor: Color, blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, - metaLine: @Composable () -> Unit + metaLine: (@Composable () -> Unit)?, + ruleMinWidth: Dp ) { val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) val cardShape = RoundedCornerShape(12.dp * roundness) @@ -61,6 +72,7 @@ fun MarkdownBlocks( val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } val fontSize = style.fontSize.takeOrElse { 16.sp } val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val terminal = LocalTerminalLayout.current val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } val lastIsText = when (blocks.lastOrNull()) { is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true @@ -72,25 +84,31 @@ fun MarkdownBlocks( } Column(modifier) { blocks.forEachIndexed { k, block -> - if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { - Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + if (k > 0 && terminal && blocks[k - 1] is MarkdownBlock.Heading && block !is MarkdownBlock.Heading) { + Spacer(Modifier.height(TERMINAL_HEADING_BOTTOM_GAP.toDp())) + } else if (k > 0 && (paragraphBreak(formattedText, blocks[k - 1].end, block.start) || terminal && block is MarkdownBlock.Heading)) { + Spacer(Modifier.height(if (terminal) TERMINAL_LEVEL_STEP.toDp() else with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) } val start = starts[k] + val marksLastBlock = k == blocks.lastIndex && metaLine == null + val cardMargins = if (!terminal) Modifier else if (marksLastBlock) Modifier.padding(top = TERMINAL_BLOCK_TOP_GAP.toDp()).messageLastBlock() else Modifier.padding(top = TERMINAL_BLOCK_TOP_GAP.toDp(), bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) when (block) { is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) is MarkdownBlock.Heading -> { - val scale = when (block.level) { + val scale = if (terminal) TERMINAL_HEADING_RATIO.pow((3 - block.level).coerceAtLeast(0)) else when (block.level) { 1 -> 1.5f 2 -> 1.3f 3 -> 1.15f else -> 1f } - BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + BlockText( + start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold), + if (terminal) Modifier.headingBox(scale, fontSize) else Modifier + ) } - is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { - Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) - Spacer(Modifier.width(10.dp)) - BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + is MarkdownBlock.Quote -> Box(Modifier.quoteBar(textColor)) { + val quoteColor = textColor.copy(alpha = 0.7f) + BlockText(start, block.content, style.copy(color = quoteColor).merge(terminalTextStyle(fontSize, quoteColor))) } is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> Row(Modifier.padding(start = 20.dp * level)) { @@ -99,13 +117,14 @@ fun MarkdownBlocks( BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) } } - is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> - BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content), cardMargins) { m -> + val codeFontSize = fontSize * 0.875f + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = codeFontSize, lineHeight = lineHeight * 0.9f).merge(terminalTextStyle(codeFontSize)), m, softWrap = false) } is MarkdownBlock.Table -> { val tableRows = listOf(block.header) + block.rows val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } - Box(Modifier.horizontalScroll(rememberScrollState())) { + Box(cardMargins.horizontalScroll(rememberScrollState())) { TableGrid( block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) @@ -121,13 +140,38 @@ fun MarkdownBlocks( } } } - is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + is MarkdownBlock.Rule -> Box((if (terminal) cardMargins.widthIn(min = ruleMinWidth) else Modifier.padding(vertical = 6.dp)).fillMaxWidth().height(1.dp).background(lineColor)) } } - if (!lastIsText) metaLine() + if (!lastIsText) metaLine?.invoke() } } +@Composable +fun Modifier.quoteBar(textColor: Color): Modifier { + val color = textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f) + val barWidth = 4.dp + return drawBehind { + val width = barWidth.roundToPx().toFloat() + drawRoundRect(color, Offset(if (layoutDirection == LayoutDirection.Ltr) 0f else size.width - width, 0f), Size(width, size.height), CornerRadius(2.dp.toPx())) + }.padding(start = barWidth + 10.dp) +} + +private fun Modifier.headingBox(scale: Float, bodyFontSize: TextUnit): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val firstBaseline = placeable[FirstBaseline] + val lastBaseline = placeable[LastBaseline] + if (firstBaseline == AlignmentLine.Unspecified || lastBaseline == AlignmentLine.Unspecified) { + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } else { + val bodyRatio = bodyFontSize.toPx() / (bodyFontSize * scale).toPx() + val top = (firstBaseline * (1 - bodyRatio) - INTER_CAP_HEIGHT * ((bodyFontSize * scale).toPx() - bodyFontSize.toPx())).roundToInt() + val bottom = ((placeable.height - lastBaseline) * (1 - bodyRatio)).roundToInt() + layout(placeable.width, placeable.height - top - bottom) { placeable.placeRelative(0, -top) } + } + } + private val MarkdownBlock.start: Int get() = when (this) { is MarkdownBlock.Rule -> mark.from else -> textRanges.firstOrNull()?.range?.from ?: 0 @@ -151,13 +195,13 @@ private fun codeText(fts: List, r: SegmentRange): String = } @Composable -private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, modifier: Modifier, content: @Composable (Modifier) -> Unit) { var tapped by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() val clipboard = LocalClipboardManager.current Box( - Modifier + modifier .fillMaxWidth() .clip(shape) .background(color) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt index 189e69a925..15758a7296 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -96,11 +96,14 @@ fun showMarkdownFile(file: CIFile) { formatted, bidiControlChar.containsMatchIn(text) ) - ModalManager.center.showModal(endButtons = { - if (formatted != null) MarkdownViewSwitch() - MarkdownFileSaveButton(file, bytes) - }) { - MarkdownFileView(file.fileName, document) + withContext(Dispatchers.Main) { + if (appPlatform.isDesktop && ModalManager.center.isTopModalOpen(ModalViewId.MARKDOWN_FILE)) ModalManager.center.closeModal() + ModalManager.center.showModal(id = ModalViewId.MARKDOWN_FILE, endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + key(this) { MarkdownFileView(file.fileName, document) } + } } } } 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 c488b4172b..67ac3b293f 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 @@ -23,19 +23,21 @@ import kotlinx.datetime.Clock 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( - shape = RoundedCornerShape(18.dp), - color = if (ci.chatDir.sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + shape = RoundedCornerShape(18.dp), + color = if (ci.chatDir.sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { - Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f, false)) { + MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) + } + inlineMeta?.invoke() } - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt new file mode 100644 index 0000000000..3682c54d9d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt @@ -0,0 +1,383 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.* +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.VerticalAlignmentLine +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.Flow +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt + +val LocalTerminalLayout = staticCompositionLocalOf { false } + +val TERMINAL_BLOCK_TOP_GAP = 5.75.sp +val TERMINAL_BLOCK_BOTTOM_GAP = 5.sp +val TERMINAL_LEVEL_STEP = 4.75.sp +val TERMINAL_HEADING_BOTTOM_GAP = TERMINAL_LEVEL_STEP / 2 +val TERMINAL_SENDER_TOP_GAP = 6.5.sp +private val TERMINAL_SENDER_BOTTOM_GAP = 5.5.sp +val TERMINAL_HEADER_TOP_GAP = 2.5.sp +val TERMINAL_HEADER_BOTTOM_GAP = 1.5.sp +val TERMINAL_EVENT_TOP_GAP = 8.25.sp +val TERMINAL_EVENT_BOTTOM_GAP = 1.5.sp +val TERMINAL_FILE_TOP_GAP = 2.sp +val TERMINAL_EMOJI_TOP_GAP = 4.25.sp +val TERMINAL_EMOJI_BOTTOM_GAP = 1.5.sp +val TERMINAL_META_GAP = 6.sp +const val TERMINAL_HEADING_RATIO = 1.3125f +private const val TERMINAL_LINE_HEIGHT_RATIO = 22f / 16f +private const val TERMINAL_LINE_HEIGHT_CONTRAST_SLOPE = 0.1515f +const val INTER_CAP_HEIGHT = 2048f / 2816f +private val TERMINAL_LINE_HEIGHT_STYLE = LineHeightStyle(LineHeightStyle.Alignment.Proportional, LineHeightStyle.Trim.None) + +@Composable +fun terminalTextStyle(fontSize: TextUnit, color: Color = messageTextColor()): TextStyle = + if (LocalTerminalLayout.current) { + TextStyle(lineHeight = fontSize * (TERMINAL_LINE_HEIGHT_RATIO * contrastLineHeightFactor(color)), lineHeightStyle = TERMINAL_LINE_HEIGHT_STYLE) + } else { + TextStyle.Default + } + +@Composable +private fun contrastLineHeightFactor(color: Color): Float { + val background = MaterialTheme.colors.background + val textContrast = lightnessContrast(messageTextColor(), background) + val weight = if (textContrast > 0f) (lightnessContrast(color, background) / textContrast).coerceIn(0f, 1f) else 1f + return 1 - TERMINAL_LINE_HEIGHT_CONTRAST_SLOPE * (1 - weight) +} + +private fun lightnessContrast(color: Color, background: Color): Float = + abs(lightness(color.compositeOver(background)) - lightness(background)) + +private fun lightness(color: Color): Float { + val y = color.luminance() + return if (y > 216f / 24389f) 116f * y.pow(1f / 3) - 16f else y * 24389f / 27f +} + +fun terminalItemShowsSender(item: ChatItem, older: ChatItem?): Boolean = + item.content.hasTerminalSender && (older == null || !older.content.hasTerminalSender || !sameSender(item, older)) + +private fun sameSender(item: ChatItem, older: ChatItem): Boolean { + val d = item.chatDir + val o = older.chatDir + return when { + item.channelIsSender || older.channelIsSender -> item.channelIsSender && older.channelIsSender + d is CIDirection.GroupRcv -> o is CIDirection.GroupRcv && o.groupMember.memberId == d.groupMember.memberId + else -> d::class == o::class + } +} + +private val ChatItem.channelIsSender: Boolean get() = + chatDir is CIDirection.ChannelRcv || (chatDir is CIDirection.GroupSnd && meta.showGroupAsSender) + +fun terminalRowGap(item: ChatItem, older: ChatItem?, separated: Boolean, largeGap: Boolean, hasSenderRow: Boolean): TextUnit = + if (older != null && !separated && (largeGap || hasSenderRow || !item.content.hasTerminalSender || !older.content.hasTerminalSender || item.textAtItemTop && item.startsWithHeading)) TERMINAL_LEVEL_STEP else 0.sp + +fun terminalHeadingGap(item: ChatItem): TextUnit = + if (!item.textAtItemTop && item.startsWithHeading) TERMINAL_LEVEL_STEP else 0.sp + +private val ChatItem.textAtItemTop: Boolean get() = + (content.msgContent is MsgContent.MCText || content.msgContent is MsgContent.MCUnknown && file == null) && !hasQuoteOrHeader + +private val ChatItem.startsWithHeading: Boolean get() = + markdownBlocks?.firstOrNull() is MarkdownBlock.Heading + +private val CIContent.hasTerminalSender: Boolean get() = when (this) { + is CIContent.SndMsgContent, + is CIContent.SndDeleted, + is CIContent.SndCall, + is CIContent.SndGroupInvitation, + is CIContent.SndModerated -> true + else -> showMemberName +} + +class TerminalRowInteractionSource : MutableInteractionSource { + private val source = MutableInteractionSource() + private var press: PressInteraction.Press? = null + var downPosition = Offset.Zero + override val interactions: Flow = source.interactions + override suspend fun emit(interaction: Interaction) = source.emit(rowInteraction(interaction)) + override fun tryEmit(interaction: Interaction): Boolean = source.tryEmit(rowInteraction(interaction)) + + private fun rowInteraction(interaction: Interaction): Interaction = when (interaction) { + is PressInteraction.Press -> PressInteraction.Press(downPosition).also { press = it } + is PressInteraction.Release -> PressInteraction.Release(press ?: interaction.press) + is PressInteraction.Cancel -> PressInteraction.Cancel(press ?: interaction.press) + else -> interaction + } +} + +@Composable +fun Modifier.terminalRowIndication(interactionSource: TerminalRowInteractionSource): Modifier = + this + .pointerInput(interactionSource) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.changedToDownIgnoreConsumed() }?.let { interactionSource.downPosition = it.position } + } + } + } + .hoverable(interactionSource) + .indication(interactionSource, LocalIndication.current) + +fun Modifier.terminalStartOffset(offset: () -> Dp): Modifier = + layout { measurable, constraints -> + val start = offset().roundToPx() + val placeable = measurable.measure(constraints.offset(horizontal = -start)) + layout(placeable.width + start, placeable.height) { placeable.placeRelative(start, 0) } + } + +private val LastLineLeft = VerticalAlignmentLine(::min) +private val LastLineRight = VerticalAlignmentLine(::max) +private val LastBlockBottom = HorizontalAlignmentLine(::max) + +fun Modifier.messageLastLine(): Modifier = lastLine { emptyMap() } + +fun Modifier.messageLastBlock(): Modifier = lastLine { mapOf(LastBlockBottom to it.height) } + +private fun Modifier.lastLine(extraAlignmentLines: (Placeable) -> Map): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height, mapOf(LastLineLeft to 0, LastLineRight to placeable.width) + extraAlignmentLines(placeable)) { placeable.placeRelative(0, 0) } + } + +@Composable +fun Modifier.inlineIcon(): Modifier = + if (LocalTerminalLayout.current) { + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, 0) { placeable.placeRelative(0, -placeable.height / 2) } + } + } else { + this + } + +@Composable +fun ItemWithMeta(meta: @Composable () -> Unit, overlayMeta: (@Composable () -> Unit)? = null, metaReserved: Boolean = false, endButton: (@Composable () -> Unit)? = null, content: @Composable (inlineMeta: (@Composable () -> Unit)?) -> Unit) { + if (LocalTerminalLayout.current) { + TerminalMetaLayout(metaReserved, hasOverlay = overlayMeta != null) { + content(null) + listOfNotNull(meta, overlayMeta, endButton).forEach { m -> + Box(Modifier.padding(end = 12.dp)) { + m() + } + } + } + } else { + content(meta) + } +} + +@Composable +private fun TerminalMetaLayout(metaReserved: Boolean, hasOverlay: Boolean, contentWithMeta: @Composable () -> Unit) { + Layout(contentWithMeta) { measurables, constraints -> + val metaConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val meta = measurables[1].measure(metaConstraints) + val overlay = if (hasOverlay) measurables[2].measure(metaConstraints) else null + val endButton = measurables.getOrNull(if (hasOverlay) 3 else 2)?.measure(metaConstraints) + val besideMeta = !metaReserved && overlay == null + val reservedWidth = max(if (besideMeta) meta.width else 0, endButton?.width ?: 0) + val contentMaxWidth = if (constraints.hasBoundedWidth) (constraints.maxWidth - reservedWidth).coerceAtLeast(0) else constraints.maxWidth + val content = measurables[0].measure(constraints.copy(minWidth = 0, maxWidth = contentMaxWidth)) + val width = if (constraints.hasBoundedWidth) constraints.maxWidth else max(content.width + reservedWidth, meta.width) + val gap = TERMINAL_META_GAP.toPx().roundToInt() + val lineEnd = if (layoutDirection == LayoutDirection.Ltr) { + content[LastLineRight].takeIf { it != AlignmentLine.Unspecified } + } else { + content[LastLineLeft].takeIf { it != AlignmentLine.Unspecified }?.let { content.width - it } + } + val metaBelow = lineEnd != null && lineEnd + meta.width > width + val textBaseline = content[LastBaseline] + val metaBaseline = meta[FirstBaseline].takeIf { it != AlignmentLine.Unspecified } ?: ((meta.height + 12.sp.toPx() * INTER_CAP_HEIGHT) / 2).roundToInt() + val metaTop = when { + metaBelow -> content.height + gap + overlay == null && textBaseline != AlignmentLine.Unspecified -> textBaseline - metaBaseline + else -> content.height - gap - meta.height + }.coerceAtLeast(0) + val blockGap = if (content[LastBlockBottom] == content.height) TERMINAL_BLOCK_BOTTOM_GAP.toPx().roundToInt() else 0 + val placedOverlay = overlay?.takeIf { content.width + meta.width > width } + val endButtonHeight = endButton?.height ?: 0 + val endButtonTop = (((if (placedOverlay != null) content.height else metaTop) - endButtonHeight) / 2).coerceAtLeast(0) + layout(width, maxOf(content.height + blockGap, metaTop + meta.height, placedOverlay?.height ?: 0, endButtonTop + endButtonHeight)) { + content.placeRelative(0, 0) + if (placedOverlay != null) { + placedOverlay.placeRelative(content.width - placedOverlay.width, (content.height - gap - placedOverlay.height).coerceAtLeast(0)) + } else { + meta.placeRelative(width - meta.width, metaTop) + } + endButton?.placeRelative(width - endButton.width, endButtonTop) + } + } +} + +class ChatItemSender(val name: String, val badge: LocalBadge?, val role: String?, val own: Boolean, val image: (@Composable () -> Unit)?) + +@Composable +fun chatItemSender( + chatInfo: ChatInfo, + cItem: ChatItem, + range: State, + reversedChatItems: State>, + showMemberInfo: (GroupInfo, GroupMember) -> Unit, + showChatInfo: () -> Unit +): ChatItemSender { + val dir = cItem.chatDir + return when { + cItem.channelIsSender && chatInfo is ChatInfo.Group -> + ChatItemSender(chatInfo.chatViewName, null, stringResource(MR.strings.channel_role_label), dir.sent) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage(MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, chatInfo.groupInfo.image, chatInfo.groupInfo.chatIconName, backgroundColor = MaterialTheme.colors.background) + } + } + dir is CIDirection.DirectRcv -> + ChatItemSender(chatInfo.displayName, chatInfo.nameBadge, null, false, null) + dir is CIDirection.GroupRcv && chatInfo is ChatInfo.Group -> { + val member = dir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = if (rangeValue != null) chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) else null to 1 + ChatItemSender( + memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, + if (memCount == 1) memberRole(member, chatInfo.isChannel) else null, + false + ) { + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + } + } + dir is CIDirection.GroupSnd && chatInfo is ChatInfo.Group -> { + val membership = chatInfo.groupInfo.membership + ChatItemSender(membership.displayName, membership.nameBadge, memberRole(membership, chatInfo.isChannel), true) { + MemberImage(membership) + } + } + chatInfo.incognito -> + ChatItemSender(stringResource(MR.strings.incognito), null, null, true, null) + else -> { + val user = chatModel.currentUser.value + ChatItemSender(user?.displayName ?: "", user?.profile?.localBadge, null, true, null) + } + } +} + +private fun memberRole(member: GroupMember, isChannel: Boolean): String? = + if (member.memberRole > GroupMemberRole.Member) member.memberRole.text(isChannel) else null + +@Composable +private fun SenderNameAndRole(sender: ChatItemSender, modifier: Modifier) { + Row(modifier) { + NameWithBadge( + sender.name, + sender.badge, + Modifier.weight(1f, false), + fontSize = 13.5.sp, + color = if (sender.own) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (sender.role != null) { + Text( + sender.role, + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } +} + +@Composable +fun terminalGutter(chatInfo: ChatInfo): Dp = + if (chatInfo is ChatInfo.Group) MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier + 4.dp else 0.dp + +@Composable +fun TerminalChatItemRow( + sender: ChatItemSender?, + gutter: Dp, + topGap: Dp, + modifier: Modifier = Modifier, + senderTopGap: Dp = TERMINAL_SENDER_TOP_GAP.toDp(), + content: @Composable () -> Unit +) { + Layout( + content = { + Box { + sender?.image?.invoke() + } + Box { + if (sender != null) { + ProvideTextStyle(LocalTextStyle.current.merge(terminalTextStyle(13.5.sp))) { + SenderNameAndRole(sender, Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_SENDER_BOTTOM_GAP.toDp())) + } + } + } + Box { + content() + } + }, + modifier + .padding(top = topGap + if (sender != null) senderTopGap else 0.dp, start = 8.dp, end = 12.dp) + .fillMaxWidth() + ) { measurables, constraints -> + val messageStart = gutter.roundToPx() + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val messageConstraints = looseConstraints.copy(maxWidth = (constraints.maxWidth - messageStart).coerceAtLeast(0)) + val avatar = measurables[0].measure(looseConstraints) + val name = measurables[1].measure(messageConstraints) + val message = measurables[2].measure(messageConstraints) + val messageTop = name.height + layout(constraints.maxWidth, max(messageTop + message.height, avatar.height)) { + avatar.placeRelative(0, 0) + name.placeRelative(messageStart, 0) + message.placeRelative(messageStart, messageTop) + } + } +} 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 9b3162744d..ec79fd73c4 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 @@ -50,6 +50,9 @@ import kotlinx.coroutines.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) +@Composable +fun messageTextColor(): Color = MaterialTheme.colors.onSurface.copy(alpha = if (MaterialTheme.colors.isLight) 1f else 0.87f) + fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) { if (sender != null) { if (senderBold) b.withStyle(boldFont) { append(sender) } @@ -65,21 +68,24 @@ private fun openMarkdownModal(modal: Format.Modal) { } private fun showFullProfileDescription(description: String) { - ModalManager.end.showModalCloseable { _ -> - ColumnWithScrollBar { - AppBarTitle(generalGetString(MR.strings.profile_description__field)) - val parsed = parseToMarkdownText(description) - MarkdownText( - description, - parsed?.formattedText, - parsed?.markdownBlocks, - toggleSecrets = true, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), - uriHandler = LocalUriHandler.current, - linkMode = chatModel.simplexLinkMode.value, - modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), - blockLayout = true, - ) + if (ModalManager.end.isTopModalOpen(ModalViewId.PROFILE_DESCRIPTION)) ModalManager.end.closeModal() + ModalManager.end.showModalCloseable(id = ModalViewId.PROFILE_DESCRIPTION) { _ -> + key(this) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) + MarkdownText( + description, + parsed?.formattedText, + parsed?.markdownBlocks, + toggleSecrets = true, + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + uriHandler = LocalUriHandler.current, + linkMode = chatModel.simplexLinkMode.value, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, + ) + } } } } @@ -171,11 +177,13 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + encrypted: Boolean? = null, + signedFileVerified: Boolean? = null, mentions: Map? = null, userMemberId: String? = null, toggleSecrets: Boolean, sendCommandMsg: ((String) -> Unit)? = null, - style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), + style: TextStyle = MaterialTheme.typography.body1.copy(color = messageTextColor(), lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, uriHandler: UriHandler? = null, @@ -201,36 +209,49 @@ fun MarkdownText ( val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } + val metaCopy = if (meta != null && LocalTerminalLayout.current) { + terminalReserveSpaceForMeta(meta, chatTTL, encrypted, signedFileVerified, MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else null if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { val direction = LocalLayoutDirection.current + val terminal = LocalTerminalLayout.current + val ruleMinWidth = if (metaCopy != null && markdownBlocks.isNotEmpty() && markdownBlocks.all { it is MarkdownBlock.Rule }) { + with(LocalDensity.current) { rememberTextMeasurer().measure(metaCopy, style).size.width.toDp() } + } else 0.dp CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { MarkdownBlocks( formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> MarkdownText( text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + encrypted = encrypted, signedFileVerified = signedFileVerified, mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction ) }, - metaLine = { - MarkdownText( - text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, - linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY - ) - } + metaLine = if (terminal && meta?.isLive != true) null else { + { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, encrypted = encrypted, signedFileVerified = signedFileVerified, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY, parentDirection = if (terminal) direction else null + ) + } + }, + ruleMinWidth = ruleMinWidth ) } return } - val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { - "\n" - } else if (meta != null) { - reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else { - " " + val reserve = meta?.let { + if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current)) { + AnnotatedString("\n") + } else if (metaCopy != null) { + metaCopy + } else { + AnnotatedString(reserveSpaceForMeta(it, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp)) + } } val scope = rememberCoroutineScope() CompositionLocalProvider( @@ -281,7 +302,7 @@ fun MarkdownText ( if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } - if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } + if (reserve != null) withStyle(reserveTimestampStyle) { append(reserve) } } val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } if (textSelection != null) { @@ -437,7 +458,7 @@ fun MarkdownText ( // With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good /*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl) withStyle(reserveTimestampStyle) { append("\n" + metaText) } - else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } + else */if (reserve != null) withStyle(reserveTimestampStyle) { append(reserve) } } val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } val layoutResult = remember { mutableStateOf(null) } 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 ea567d7da6..e3f6b92be8 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 @@ -274,8 +274,12 @@ fun UserPicker( painterResource(MR.images.ic_manage_accounts), generalGetString(MR.strings.create_chat_profile), { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) { + userPickerState.value = AnimatedViewState.HIDING + return@UserPickerOptionRow + } doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> LaunchedEffect(Unit) { userPickerState.value = AnimatedViewState.HIDING } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 036bcab68d..b09efe7d1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -252,7 +252,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) text = supports, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title = title, text = supports) 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 3655c4053d..7f6ee13d98 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 @@ -9,6 +9,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.input.pointer.pointerInput import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import LocalCardScreen @@ -93,7 +94,11 @@ enum class ModalViewId { CONTEXT_USER_PICKER_INCOGNITO, BADGE_LINK, BADGE_REDEEM_CODE, - BADGES + BADGES, + CREATE_PROFILE, + PROFILE_DESCRIPTION, + MARKDOWN_FILE, + CHAT_ITEM_INFO } class ModalManager(private val placement: ModalPlacement? = null) { @@ -118,6 +123,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isTopModalOpen(id: ModalViewId): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { @@ -224,6 +231,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } + // Until the opening modal covers the page that opened it, or the view under the first modal, a second click there would open it again + if (transition.targetState == EnterExitState.PostExit && it < modalCount.value) { + Box(Modifier.fillMaxSize().pointerInput(Unit) {}) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index 98ea245886..807d1b2f54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -13,9 +13,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatLayoutMode import chat.simplex.common.model.yaml import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.LocalTerminalLayout import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector import chat.simplex.common.views.usersettings.AppearanceScope.editColor @@ -95,11 +97,13 @@ fun ModalData.UserWallpaperEditor( } } + val terminal = LocalTerminalLayout.current val editColor = { name: ThemeColor -> editColor( name, wallpaperType, wallpaperImage, + terminal, onColorChange = { color -> preApplyGlobalIfNeeded(themeModeOverride.value.type) ThemeManager.applyThemeColor(name, color, themeModeOverride) @@ -235,6 +239,7 @@ fun ModalData.ChatWallpaperEditor( theme: ThemeModeOverride, applyToMode: DefaultThemeMode?, globalThemeUsed: MutableState, + layoutSection: @Composable () -> Unit, save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit ) { ColumnWithScrollBar { @@ -249,6 +254,10 @@ fun ModalData.ChatWallpaperEditor( AppBarTitle(stringResource(MR.strings.settings_section_title_chat_theme)) + layoutSection() + + SectionDividerSpaced() + val onTypeCopyFromSameTheme: (WallpaperType?) -> Boolean = { type -> if (type is WallpaperType.Image && chatModel.remoteHostId() != null) { false @@ -277,6 +286,7 @@ fun ModalData.ChatWallpaperEditor( withBGApi { save(applyToMode.value, themeModeOverride.value) } } + val terminal = LocalTerminalLayout.current val editColor: (ThemeColor) -> Unit = { name: ThemeColor -> ModalManager.end.showModal { val currentTheme by remember(themeModeOverride.value, CurrentColors.collectAsState().value) { @@ -300,23 +310,25 @@ fun ModalData.ChatWallpaperEditor( ThemeColor.RECEIVED_MESSAGE -> currentTheme.appColors.receivedMessage ThemeColor.RECEIVED_QUOTE -> currentTheme.appColors.receivedQuote } - AppearanceScope.ColorEditor( - name, - initialColor, - CurrentColors.collectAsState().value.base, - themeModeOverride.value.type, - themeModeOverride.value.type?.image, - currentTheme.wallpaper.background, - currentTheme.wallpaper.tint, - currentColors = { - ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()) - }, - onColorChange = { color -> - preApplyGlobalIfNeeded(themeModeOverride.value.type) - ThemeManager.applyThemeColor(name, color, themeModeOverride) - withBGApi { save(applyToMode.value, themeModeOverride.value) } - } - ) + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + AppearanceScope.ColorEditor( + name, + initialColor, + CurrentColors.collectAsState().value.base, + themeModeOverride.value.type, + themeModeOverride.value.type?.image, + currentTheme.wallpaper.background, + currentTheme.wallpaper.tint, + currentColors = { + ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()) + }, + onColorChange = { color -> + preApplyGlobalIfNeeded(themeModeOverride.value.type) + ThemeManager.applyThemeColor(name, color, themeModeOverride) + withBGApi { save(applyToMode.value, themeModeOverride.value) } + } + ) + } } } @@ -524,6 +536,14 @@ private fun SetDefaultThemeButton(onClick: () -> Unit) { } } +fun chatLayoutName(layout: ChatLayoutMode): String = when (layout) { + is ChatLayoutMode.Classic -> generalGetString(MR.strings.chat_layout_classic) + is ChatLayoutMode.Terminal -> generalGetString(MR.strings.chat_layout_terminal) + is ChatLayoutMode.Unknown -> layout.type +} + +fun otherChatLayout(layout: ChatLayoutMode): ChatLayoutMode = if (layout == ChatLayoutMode.Terminal) ChatLayoutMode.Classic else ChatLayoutMode.Terminal + @Composable private fun AdvancedSettingsButton(onClick: () -> Unit) { SettingsActionItem( 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 e2c89940ab..f3c43b8a5c 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 @@ -39,8 +39,11 @@ import java.net.URI fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) { val rhId = rh?.remoteHostId val view = LocalMultiplatformView() + val inProgress = remember { mutableStateOf(false) } AddGroupLayout( createGroup = { incognito, groupProfile -> + if (inProgress.value) return@AddGroupLayout + inProgress.value = true hideKeyboard(view) withBGApi { val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) @@ -61,6 +64,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } + } else { + inProgress.value = false } } }, 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 f6109778dc..82cffbb384 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 @@ -66,8 +66,9 @@ fun ContactConnectionInfoView( deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, learnMore = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() ModalManager.end.showModalCloseable { close -> - AddContactLearnMore(close) + key(this) { AddContactLearnMore(close) } } } ) @@ -90,7 +91,10 @@ private fun ContactConnectionInfoLayout( SettingsActionItemWithContent( icon = painterResource(MR.images.ic_theater_comedy_filled), text = null, - click = { ModalManager.end.showModal { IncognitoView() } }, + click = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() + ModalManager.end.showModal { key(this) { IncognitoView() } } + }, iconColor = Indigo, extraPadding = false ) { 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 a661568a0d..179ec48175 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.TextFieldValue @@ -38,7 +39,12 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex +import chat.simplex.common.views.chat.item.ChatItemSender +import chat.simplex.common.views.chat.item.LocalTerminalLayout import chat.simplex.common.views.chat.item.PreviewChatItemView +import chat.simplex.common.views.chat.item.TERMINAL_SENDER_TOP_GAP +import chat.simplex.common.views.chat.item.TerminalChatItemRow +import chat.simplex.common.views.chat.item.terminalRowGap import chat.simplex.common.views.chat.item.msgTailWidthDp import chat.simplex.res.* import com.godaddy.android.colorpicker.ClassicColorPicker @@ -279,13 +285,14 @@ object AppearanceScope { val themeBackgroundColor = MaterialTheme.colors.background val backgroundColor = backgroundColor ?: wallpaperType?.defaultBackgroundColor(theme, MaterialTheme.colors.background) val tintColor = tintColor ?: wallpaperType?.defaultTintColor(theme) + val terminal = LocalTerminalLayout.current Column(Modifier .drawWithCache { - if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null) { + if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null && !terminal) { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, null, null) } else { onDrawBehind { - drawRect(themeBackgroundColor) + drawRect(backgroundColor ?: themeBackgroundColor) } } } @@ -293,22 +300,54 @@ object AppearanceScope { ) { if (withMessages) { val chatItemTail = remember { appPreferences.chatItemTail.state } + val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } + val reply = ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), + quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) + ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { - val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } - PreviewChatItemView(alice) - PreviewChatItemView( - ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), - quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) - ) - ) + @Composable + fun TerminalPreviewRow(item: ChatItem, older: ChatItem?, sender: ChatItemSender) { + TerminalChatItemRow(sender, gutter = 0.dp, topGap = terminalRowGap(item, older, separated = false, largeGap = true, hasSenderRow = true).toDp(), senderTopGap = if (older == null) 0.dp else TERMINAL_SENDER_TOP_GAP.toDp()) { + PreviewChatItemView(item) + } } + + PreviewInEitherLayout( + terminal, + classic = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { + PreviewChatItemView(alice) + PreviewChatItemView(reply) + } + }, + terminalRows = { + Column { + TerminalPreviewRow(alice, null, ChatItemSender("alice", null, null, own = false, image = null)) + TerminalPreviewRow(reply, alice, ChatItemSender("bob", null, null, own = true, image = null)) + } + } + ) } else { Box(Modifier.fillMaxSize()) } } } + @Composable + private fun PreviewInEitherLayout(terminal: Boolean, classic: @Composable () -> Unit, terminalRows: @Composable () -> Unit) { + Layout({ + CompositionLocalProvider(LocalTerminalLayout provides false, content = classic) + CompositionLocalProvider(LocalTerminalLayout provides true, content = terminalRows) + }) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val shown = placeables[if (terminal) 1 else 0] + val height = placeables.maxOf { it.height } + layout(placeables.maxOf { it.width }, height) { + shown.placeRelative(0, (height - shown.height) / 2) + } + } + } + @Composable fun WallpaperPresetSelector( selectedWallpaper: WallpaperType?, @@ -326,6 +365,7 @@ object AppearanceScope { } val backgrounds = PresetWallpaper.entries.toList() + val terminal = LocalTerminalLayout.current fun LazyGridScope.gridContent(width: Dp, height: Dp) { @Composable @@ -398,8 +438,10 @@ object AppearanceScope { items(items = backgrounds) { background -> BackgroundItem(background) } - item { - OwnBackgroundItem(selectedWallpaper) + if (!terminal) { + item { + OwnBackgroundItem(selectedWallpaper) + } } } @@ -417,7 +459,7 @@ object AppearanceScope { val gridPadding = 12.dp val cardPadding = if (LocalCardScreen.current) CARD_PADDING * 2 else 0.dp val itemSize = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - cardPadding - gridPadding * 5) / 4 - val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() + val rows = ceil((PresetWallpaper.entries.size + if (terminal) 1 else 2) / 4f).roundToInt() LazyVerticalGrid( columns = GridCells.Fixed(4), Modifier.height(itemSize * rows + gridPadding * (rows + 1)), @@ -530,19 +572,22 @@ object AppearanceScope { SectionView(stringResource(MR.strings.settings_section_title_themes)) { ThemeDestinationPicker(themeUserDestination) + ChatLayoutRow(themeUserDestination) val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - currentColors(type) - }, - onChooseType = { onChooseType(it, importWallpaperLauncher) }, - ) + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(if (themeUserDestination.value == null) null else chatModel.currentUser.value)) { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + currentColors(type) + }, + onChooseType = { onChooseType(it, importWallpaperLauncher) }, + ) + } val type = MaterialTheme.wallpaper.type if (type is WallpaperType.Image && (themeUserDestination.value == null || perUserTheme.value.wallpaper?.imageFile != null)) { SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, click = { @@ -596,7 +641,9 @@ object AppearanceScope { val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) } - CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(null)) { + CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + } } } else { ModalManager.start.showModalCloseable(cardScreen = true) { close -> @@ -619,11 +666,13 @@ object AppearanceScope { val wallpaperType = MaterialTheme.wallpaper.type val baseTheme = CurrentColors.collectAsState().value.base + val terminal = LocalTerminalLayout.current val editColor = { name: ThemeColor -> editColor( name, wallpaperType, wallpaperImage, + terminal, onColorChange = { color -> ThemeManager.saveAndApplyThemeColor(baseTheme, name, color) saveThemeToDatabase(null) @@ -787,7 +836,7 @@ object AppearanceScope { } } - fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, onColorChange: (Color?) -> Unit) { + fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, terminal: Boolean, onColorChange: (Color?) -> Unit) { ModalManager.start.showModal { val baseTheme = CurrentColors.collectAsState().value.base val wallpaperBackgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(baseTheme, MaterialTheme.colors.background) @@ -808,9 +857,11 @@ object AppearanceScope { ThemeColor.RECEIVED_MESSAGE -> MaterialTheme.appColors.receivedMessage ThemeColor.RECEIVED_QUOTE -> MaterialTheme.appColors.receivedQuote } - ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, - onColorChange = onColorChange - ) + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, + onColorChange = onColorChange + ) + } } } @@ -823,13 +874,15 @@ object AppearanceScope { globalThemeUsed.value = preferred == null preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - UserWallpaperEditor( - initialTheme, - applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, - globalThemeUsed = globalThemeUsed, - save = { applyToMode, newTheme -> - save(applyToMode, newTheme, themes.value, userId, remoteHostId) - }) + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(chatModel.currentUser.value)) { + UserWallpaperEditor( + initialTheme, + applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, + globalThemeUsed = globalThemeUsed, + save = { applyToMode, newTheme -> + save(applyToMode, newTheme, themes.value, userId, remoteHostId) + }) + } KeyChangeEffect(chatModel.currentUser.value?.userId, chatModel.remoteHostId) { close() } @@ -871,7 +924,7 @@ object AppearanceScope { } changedThemes } else { - null + changedThemes?.layoutOnly() } val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile @@ -890,6 +943,40 @@ object AppearanceScope { } } + private fun profileLayoutIsTerminal(user: User?): Boolean = + profileChatLayout(user) == ChatLayoutMode.Terminal + + @Composable + private fun ChatLayoutRow(themeUserDestination: MutableState?>) { + val globalLayout = remember { derivedStateOf { profileChatLayout(null) } } + val dest = themeUserDestination.value + if (dest == null) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + listOf(ChatLayoutMode.Classic, ChatLayoutMode.Terminal).map { it to chatLayoutName(it) }, + globalLayout, + onSelected = { layout -> appPrefs.chatLayout.set(layout) } + ) + } else { + val (userId, themes) = dest + val userLayout = themes?.chatLayout + val otherLayout = otherChatLayout(globalLayout.value) + val selection = remember(userLayout, otherLayout) { mutableStateOf(if (userLayout == otherLayout) otherLayout else null) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + listOf( + null to "${chatLayoutName(globalLayout.value)} ${generalGetString(MR.strings.chat_layout_default_suffix)}", + otherLayout to chatLayoutName(otherLayout) + ), + selection, + onSelected = { layout -> + themeUserDestination.value = userId to themes.withChatLayout(layout) + saveThemeToDatabase(themeUserDestination.value) + } + ) + } + } + @Composable fun ThemeDestinationPicker(themeUserDestination: MutableState?>) { val themeUserDest = remember(themeUserDestination.value?.first) { mutableStateOf(themeUserDestination.value?.first) } @@ -897,7 +984,7 @@ object AppearanceScope { if (themeUserDestination.value == null) { // Easiest way to hide per-user customization. // Otherwise, it would be needed to make global variable and to use it everywhere for making a decision to include these overrides into active theme constructing or not - chatModel.currentUser.value = chatModel.currentUser.value?.copy(uiThemes = null) + chatModel.currentUser.value = chatModel.currentUser.value?.let { it.copy(uiThemes = it.uiThemes?.layoutOnly()) } } else { chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) } @@ -1198,10 +1285,11 @@ fun WallpaperSetupView( firstSectionTitle: String? = null, ) { val hasWallpaperSettings = wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image + val terminal = LocalTerminalLayout.current if (hasWallpaperSettings) { SectionView(firstSectionTitle) { - if (wallpaperType is WallpaperType.Image) { + if (wallpaperType is WallpaperType.Image && !terminal) { val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } val values = remember { WallpaperScaleType.entries.map { it to generalGetString(it.text) } @@ -1216,7 +1304,7 @@ fun WallpaperSetupView( ) } - if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { + if ((wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) && !terminal) { val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) @@ -1240,11 +1328,13 @@ fun WallpaperSetupView( Text(title) Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) } - val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { - val title = generalGetString(MR.strings.color_wallpaper_tint) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + if (!terminal) { + val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { + val title = generalGetString(MR.strings.color_wallpaper_tint) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + } } } SectionDividerSpaced() @@ -1290,7 +1380,7 @@ private fun ColorPicker(initialColor: Color, showAlphaBar: Boolean, onColorChang private fun removeUserThemeModeOverrides(themeUserDestination: MutableState?>, perUserTheme: MutableState) { val dest = themeUserDestination.value ?: return perUserTheme.value = ThemeModeOverride() - themeUserDestination.value = dest.first to null + themeUserDestination.value = dest.first to dest.second?.layoutOnly() val wallpaperFilesToDelete = listOf( (chatModel.currentUser.value?.uiThemes?.light?.type as? WallpaperType.Image)?.filename, (chatModel.currentUser.value?.uiThemes?.dark?.type as? WallpaperType.Image)?.filename 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 826648ddb8..674aa3831b 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 @@ -633,14 +633,16 @@ private fun BusinessAddressToggle(addressSettingsState: MutableState, profileHidden: showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice, visibleUsersCount = visibleUsersCount(m), addUser = { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) return@UserProfilesLayout withAuth { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> CreateProfile(m, close) } } diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index c6d31955f9..565554f7e8 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -2304,6 +2304,11 @@ All color modes Light mode Dark mode + Chat layout + Classic + Terminal + (chat) + (default) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 6fb3677a2e..0e2c1f7b74 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -145,7 +145,7 @@ const processCommand = (function () { } }); } - async function initializeCall(config, mediaType, aesKey) { + async function initializeCall(config, mediaType, aesKey, canReconnect) { var _a, _b, _c; let pc; try { @@ -233,7 +233,7 @@ const processCommand = (function () { : pc.iceConnectionState == "completed" ? "connected" : "connecting"); /* webView 69-70 doesn't have connectionState yet */ - if (wasConnected) { + if (wasConnected && canReconnect) { if (reconnectTimedOut) { reconnectingTimeout = undefined; if (activeCall !== call || state == "connected") @@ -415,10 +415,10 @@ const processCommand = (function () { inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSourcesChanged(inactiveCallMediaSources); setupLocalVideoRatio(getVideoElements().local); - const { media, iceServers, relay } = command; + const { media, iceServers, relay, canReconnect } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, canReconnect); await setupLocalStream(true, activeCall); setupCodecPreferences(activeCall); const pc = activeCall.connection; @@ -458,8 +458,8 @@ const processCommand = (function () { else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); + const { media, aesKey, iceServers, relay, canReconnect } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, canReconnect); const pc = activeCall.connection; // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(!webView69Or70() ? offer : adaptSdpToOldWebView(offer))); diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt new file mode 100644 index 0000000000..91b7854dae --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt @@ -0,0 +1,234 @@ +package chat.simplex.app + +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.ListItem +import chat.simplex.common.views.chat.MergedItem +import chat.simplex.common.views.chat.terminalRowsSeparated +import chat.simplex.common.views.chat.item.TERMINAL_LEVEL_STEP +import chat.simplex.common.views.chat.item.terminalHeadingGap +import chat.simplex.common.views.chat.item.terminalItemShowsSender +import chat.simplex.common.views.chat.item.terminalRowGap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TerminalItemViewTest { + private val noon = Instant.parse("2026-10-12T12:00:00Z") + private val alice = GroupMember.sampleData.copy(groupMemberId = 1, memberId = "alice") + private val bob = GroupMember.sampleData.copy(groupMemberId = 2, memberId = "bob") + + private fun msgAt(id: Long, dir: CIDirection, ts: Instant) = + ChatItem.getSampleData(id, dir, ts).let { + if (dir.sent) it else it.copy(content = CIContent.RcvMsgContent(MsgContent.MCText("hello"))) + } + + private fun msg(id: Long, dir: CIDirection, secondsAfterNoon: Long) = + msgAt(id, dir, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon)) + + private fun integrityError(id: Long, secondsAfterNoon: Long) = + msg(id, CIDirection.DirectRcv(), secondsAfterNoon).copy(content = CIContent.RcvIntegrityError(MsgErrorType.MsgSkipped(1, 2))) + + private fun featureEvent(id: Long, secondsAfterNoon: Long) = + ChatItem( + CIDirection.DirectRcv(), + CIMeta.getSample(id, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon), ""), + CIContent.RcvChatFeature(ChatFeature.Voice, FeatureEnabled(forUser = true, forContact = true)), + reactions = emptyList() + ) + + @Test + fun senderShownForOldestItem() { + assertTrue(terminalItemShowsSender(msg(1, CIDirection.DirectRcv(), 0), older = null)) + } + + @Test + fun senderHiddenForSameSender() { + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectSnd(), 30), older = msg(1, CIDirection.DirectSnd(), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(alice), 59), older = msg(1, CIDirection.GroupRcv(alice), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 3600), older = msg(1, CIDirection.DirectRcv(), 0))) + } + + @Test + fun senderShownForDifferentGroupMember() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(bob), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun senderShownWhenDirectionChanges() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.DirectSnd(), 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupSnd(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun senderShownAfterEventItem() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = featureEvent(1, 0))) + } + + @Test + fun senderHiddenOnDateChange() { + val midnight = LocalDate(2026, 10, 13).atStartOfDayIn(TimeZone.currentSystemDefault()) + val older = msgAt(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds - 10)) + val item = msgAt(2, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds + 10)) + assertFalse(terminalItemShowsSender(item, older)) + } + + @Test + fun senderShownWhenChannelPostFollowsMemberMessage() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(alice), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + assertTrue(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + } + + @Test + fun senderShownWhenOwnerSwitchesBetweenChannelAndOwnName() { + val asChannel = msg(1, CIDirection.GroupSnd(), 0).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupSnd(), 10), older = asChannel)) + val asChannelToo = msg(2, CIDirection.GroupSnd(), 10).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertFalse(terminalItemShowsSender(asChannelToo, older = asChannel)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = asChannel)) + } + + @Test + fun integrityErrorGroupedWithSender() { + assertFalse(terminalItemShowsSender(integrityError(2, 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertFalse(terminalItemShowsSender(msg(3, CIDirection.DirectRcv(), 20), older = integrityError(2, 10))) + } + + @Test + fun senderHiddenAfterDeletedMessageFromSameSender() { + val deleted = msg(1, CIDirection.DirectRcv(), 0).let { it.copy(meta = it.meta.copy(itemDeleted = CIDeleted.Deleted(null))) } + val legacyDeleted = msg(1, CIDirection.DirectRcv(), 0).copy(content = CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = deleted)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = legacyDeleted)) + } + + @Test + fun senderGroupsLocalNotes() { + assertTrue(terminalItemShowsSender(msg(1, CIDirection.LocalSnd(), 0), older = null)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.LocalSnd(), 10), older = msg(1, CIDirection.LocalSnd(), 0))) + } + + @Test + fun noSenderForEventItem() { + assertFalse(terminalItemShowsSender(featureEvent(2, 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertFalse(terminalItemShowsSender(featureEvent(1, 0), older = null)) + } + + private fun withBlocks(item: ChatItem, vararg blocks: MarkdownBlock) = item.copy(markdownBlocks = blocks.toList()) + + private fun heading() = MarkdownBlock.Heading(1, SegmentRange(0, 1)) + + private fun quoted(item: ChatItem) = item.copy(quotedItem = CIQuote(CIDirection.DirectRcv(), 1, sentAt = noon, content = MsgContent.MCText("question"))) + + private fun forwarded(item: ChatItem) = item.copy(meta = item.meta.copy(itemForwarded = CIForwardedFrom.Unknown)) + + private fun gap(item: ChatItem, older: ChatItem?, separated: Boolean = false, largeGap: Boolean = false, hasSenderRow: Boolean = false) = + terminalRowGap(item, older, separated, largeGap, hasSenderRow) + + private fun single(item: ChatItem, prev: ChatItem?) = MergedItem.Single(ListItem(item, prev, null, 0), 0) + + private fun grouped(item: ChatItem, prev: ChatItem?, revealed: Boolean) = + MergedItem.Grouped(arrayListOf(ListItem(item, prev, null, 0)), revealed, mutableListOf(), MutableStateFlow(0..0), item.mergeCategory, mutableSetOf(), 0) + + @Test + fun rowGapZeroForOldestItem() { + assertEquals(0.sp, gap(msg(1, CIDirection.DirectSnd(), 0), older = null, largeGap = true, hasSenderRow = true)) + } + + @Test + fun rowGapZeroBetweenCloseMessages() { + assertEquals(0.sp, gap(msg(2, CIDirection.DirectSnd(), 10), msg(1, CIDirection.DirectSnd(), 0))) + } + + @Test + fun rowGapAfterLargeGapOrAboveSenderRow() { + val older = msg(1, CIDirection.DirectSnd(), 0) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectSnd(), 70), older, largeGap = true)) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectSnd(), 10), older, hasSenderRow = true)) + } + + @Test + fun rowGapNextToEvent() { + assertEquals(TERMINAL_LEVEL_STEP, gap(featureEvent(2, 10), msg(1, CIDirection.DirectRcv(), 0))) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectRcv(), 10), featureEvent(1, 0))) + } + + @Test + fun rowGapZeroWhenRowsAreSeparated() { + assertEquals(0.sp, gap(featureEvent(2, 10), msg(1, CIDirection.DirectRcv(), 0), separated = true, largeGap = true, hasSenderRow = true)) + } + + @Test + fun rowsSeparatedByDateBannerOrRevealedRow() { + val midnight = LocalDate(2026, 10, 13).atStartOfDayIn(TimeZone.currentSystemDefault()) + val older = msgAt(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds - 10)) + val nextDay = msgAt(2, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds + 10)) + assertFalse(terminalRowsSeparated(null)) + assertTrue(terminalRowsSeparated(single(older, nextDay))) + assertFalse(terminalRowsSeparated(single(msg(1, CIDirection.DirectRcv(), 0), msg(2, CIDirection.DirectRcv(), 10)))) + val banner = ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(1, Instant.fromEpochSeconds(midnight.epochSeconds + 5), ""), CIContent.ChatBanner, reactions = emptyList()) + assertTrue(terminalRowsSeparated(single(banner, nextDay))) + val event = featureEvent(1, 0).copy(meta = featureEvent(1, 0).meta.copy(itemTs = older.meta.itemTs)) + assertFalse(terminalRowsSeparated(grouped(event, nextDay, revealed = false))) + assertTrue(terminalRowsSeparated(grouped(event, nextDay, revealed = true))) + } + + @Test + fun headingAtItemTopTakesRowGap() { + val item = withBlocks(msg(2, CIDirection.DirectSnd(), 10), heading()) + assertEquals(TERMINAL_LEVEL_STEP, gap(item, msg(1, CIDirection.DirectSnd(), 0))) + assertEquals(0.sp, terminalHeadingGap(item)) + } + + @Test + fun headingBelowQuoteOrHeaderTakesHeadingGap() { + val older = msg(1, CIDirection.DirectSnd(), 0) + listOf(quoted(msg(2, CIDirection.DirectSnd(), 10)), forwarded(msg(2, CIDirection.DirectSnd(), 10))).forEach { + val item = withBlocks(it, heading()) + assertEquals(0.sp, gap(item, older)) + assertEquals(TERMINAL_LEVEL_STEP, terminalHeadingGap(item)) + } + } + + @Test + fun headingBelowDeletionLiveFileOrMediaTakesHeadingGap() { + val older = msg(1, CIDirection.DirectSnd(), 0) + val base = msg(2, CIDirection.DirectSnd(), 10) + val file = CIFile(fileId = 1, fileName = "a.txt", fileSize = 1, fileStatus = CIFileStatus.SndComplete, fileProtocol = FileProtocol.XFTP) + listOf( + base.copy(meta = base.meta.copy(itemDeleted = CIDeleted.Deleted(null))), + base.copy(meta = base.meta.copy(itemLive = true)), + base.copy(content = CIContent.SndMsgContent(MsgContent.MCUnknown(text = "# Notes", json = JsonPrimitive("unknown"))), file = file), + base.copy(content = CIContent.SndMsgContent(MsgContent.MCImage("# Caption", ""))) + ).forEach { + val item = withBlocks(it, heading()) + assertEquals(0.sp, gap(item, older)) + assertEquals(TERMINAL_LEVEL_STEP, terminalHeadingGap(item)) + } + } + + @Test + fun headingInUnknownContentWithoutFileTakesRowGap() { + val base = msg(2, CIDirection.DirectSnd(), 10) + val item = withBlocks(base.copy(content = CIContent.SndMsgContent(MsgContent.MCUnknown(text = "# Notes", json = JsonPrimitive("unknown")))), heading()) + assertEquals(TERMINAL_LEVEL_STEP, gap(item, msg(1, CIDirection.DirectSnd(), 0))) + assertEquals(0.sp, terminalHeadingGap(item)) + } + + @Test + fun noHeadingGapsWithoutLeadingHeading() { + val older = msg(1, CIDirection.DirectSnd(), 0) + val headingSecond = withBlocks(msg(2, CIDirection.DirectSnd(), 10), MarkdownBlock.Paragraph(SegmentRange(0, 1)), heading()) + assertEquals(0.sp, gap(headingSecond, older)) + assertEquals(0.sp, terminalHeadingGap(headingSecond)) + assertEquals(0.sp, terminalHeadingGap(quoted(msg(2, CIDirection.DirectSnd(), 10)))) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt index d7dc1ca859..8006b4febb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt @@ -3,13 +3,20 @@ package chat.simplex.common.ui.theme import androidx.compose.runtime.Composable import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.desktopPlatform import com.jthemedetecor.OsThemeDetector private val detector: OsThemeDetector = OsThemeDetector.getDetector() .apply { registerListener(::reactOnDarkThemeChanges) + if (desktopPlatform.isLinux()) Runtime.getRuntime().addShutdownHook(Thread(::stopGsettingsMonitor)) } +private fun stopGsettingsMonitor() = + ProcessHandle.current().children() + .filter { it.info().command().orElse("").contains("gsettings") } + .forEach { it.destroy() } + // TODO: explore possibility to use //@Composable //actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt new file mode 100644 index 0000000000..0c515695b1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt @@ -0,0 +1,53 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.chat.item.* +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatEventTextTest { + private val event = ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead()), CIContent.RcvDirectE2EEInfo(E2EEInfo(pqEnabled = false)), reactions = listOf()) + + private fun separatorAndTime(terminal: Boolean): Pair { + var result: Pair? = null + renderScene { + SimpleXTheme { + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + ChatEventWithMeta(event) { separator, ts -> + result = separator to ts + Box(Modifier.size(1.dp)) + } + } + } + } + return result!! + } + + @Test + fun classicEventTextUnchanged() { + val (separator, ts) = separatorAndTime(terminal = false) + assertEquals(AnnotatedString(" "), separator) + assertEquals(AnnotatedString(event.timestampText), ts) + val base = buildAnnotatedString { withStyle(chatEventStyle) { append("alice joined the group ${event.timestampText}") } } + assertEquals(base, chatEventText("alice joined the group", separator + ts)) + } + + @Test + fun terminalSeparatorJoinsCopyByNoBreakSpaces() { + val (separator, ts) = separatorAndTime(terminal = true) + assertEquals("\u00A0\u00A0\u00A0", separator.text) + assertEquals(listOf(chatEventStyle, reserveTimestampStyle), separator.spanStyles.map { it.item }) + assertTrue(ts.text.startsWith("\u2060\u200E") && ts.text.endsWith(event.timestampText.replace(' ', '\u00A0')), ts.text) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt new file mode 100644 index 0000000000..392edf225c --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt @@ -0,0 +1,34 @@ +package chat.simplex.app + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt + +data class Placed(val x: Int, val y: Int, val width: Int, val height: Int) + +fun Modifier.record(into: MutableMap, name: String): Modifier = onGloballyPositioned { + val p = it.positionInRoot() + into[name] = Placed(p.x.roundToInt(), p.y.roundToInt(), it.size.width, it.size.height) +} + +fun Modifier.fixed(width: Int, height: Int, baseline: Int?): Modifier = layout { measurable, _ -> + val placeable = measurable.measure(Constraints.fixed(width, height)) + layout(width, height, if (baseline == null) emptyMap() else mapOf(FirstBaseline to baseline, LastBaseline to baseline)) { placeable.place(0, 0) } +} + +fun renderScene(direction: LayoutDirection = LayoutDirection.Ltr, content: @Composable () -> Unit) { + val scene = ImageComposeScene(width = 400, height = 400, density = Density(1f), layoutDirection = direction, content = content) + scene.render() + scene.close() +} + +fun Placed.relativeTo(origin: Placed) = Placed(x - origin.x, y - origin.y, width, height) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt new file mode 100644 index 0000000000..d0ce540765 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt @@ -0,0 +1,66 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID +import chat.simplex.common.views.chat.item.PriorityLayout +import kotlin.test.Test +import kotlin.test.assertEquals + +class PriorityLayoutTest { + private val placed = mutableMapOf() + + private fun layOut(limitToPriorityWidth: Boolean, direction: LayoutDirection = LayoutDirection.Ltr): Map { + renderScene(direction) { + Box(Modifier.width(300.dp)) { + PriorityLayout(Modifier.record(placed, "layout"), CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth) { + Box(Modifier.width(280.dp).height(30.dp).record(placed, "header")) + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(200.dp, 100.dp)) + Box(Modifier.fillMaxWidth().height(40.dp).record(placed, "caption")) + } + } + } + val layout = placed.getValue("layout") + return listOf("layout", "header", "media", "caption").associateWith { placed.getValue(it).relativeTo(layout) } + } + + @Test + fun terminalElementsAtTheirOwnWidth() { + val r = layOut(limitToPriorityWidth = false) + assertEquals(Placed(0, 0, 300, 170), r["layout"]) + assertEquals(Placed(0, 0, 280, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 300, 40), r["caption"]) + } + + @Test + fun classicElementsAtMediaWidth() { + val r = layOut(limitToPriorityWidth = true) + assertEquals(Placed(0, 0, 200, 170), r["layout"]) + assertEquals(Placed(0, 0, 200, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 200, 40), r["caption"]) + } + + @Test + fun terminalElementsAtStartEdgeRightToLeft() { + val r = layOut(limitToPriorityWidth = false, direction = LayoutDirection.Rtl) + assertEquals(Placed(20, 0, 280, 30), r["header"]) + assertEquals(Placed(100, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 300, 40), r["caption"]) + } + + @Test + fun classicPlacementUnmirroredRightToLeft() { + val r = layOut(limitToPriorityWidth = true, direction = LayoutDirection.Rtl) + assertEquals(Placed(0, 0, 200, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt new file mode 100644 index 0000000000..483ad6a9a3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt @@ -0,0 +1,158 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.Inter +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.chat.item.LocalTerminalLayout +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.chat.item.reserveSpaceForMeta +import chat.simplex.common.views.chat.item.terminalReserveSpaceForMeta +import kotlinx.datetime.Clock +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TerminalMetaCopyTest { + private val resolver = createFontFamilyResolver() + private val iconSpace = " \u00A0\u00A0\u00A0" + private val sentEdited = CIMeta.getSample(1, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), itemEdited = true) + private val receivedTimed = CIMeta.getSample(2, Clock.System.now(), "", status = CIStatus.RcvRead(), itemTimed = CITimed(30, null)) + private val sentViaProxy = CIMeta.getSample(3, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), sentViaProxy = true) + + private fun copy(meta: CIMeta, density: Density, direction: LayoutDirection, showViaProxy: Boolean = false) = + terminalReserveSpaceForMeta( + meta, null, null, null, Color.Gray, showStatus = true, showEdited = true, showViaProxy = showViaProxy, showTimestamp = true, + density = density, bodyFontSize = 16.sp, direction = direction + ) + + private fun paragraph(text: AnnotatedString, density: Density, textDirection: TextDirection, width: Int) = + Paragraph( + text = text.text, + style = TextStyle(fontFamily = Inter, fontSize = 16.sp, textDirection = textDirection), + constraints = Constraints(maxWidth = width), + density = density, + fontFamilyResolver = resolver, + spanStyles = text.spanStyles + ) + + private fun Paragraph.bounds(range: IntRange): Rect = + range.map { getBoundingBox(it) }.reduce { a, b -> Rect(minOf(a.left, b.left), minOf(a.top, b.top), maxOf(a.right, b.right), maxOf(a.bottom, b.bottom)) } + + @Test + fun classicReserveUnchanged() { + assertEquals(iconSpace + iconSpace + "\u00A0" + sentEdited.timestampText, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = true)) + assertEquals(iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText, reserveSpaceForMeta(receivedTimed, null, null, Color.Gray, showTimestamp = true)) + assertEquals(iconSpace + iconSpace + iconSpace, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = false)) + assertEquals(iconSpace + iconSpace + "\u00A0" + sentViaProxy.timestampText, reserveSpaceForMeta(sentViaProxy, null, null, Color.Gray, showViaProxy = true, showTimestamp = true)) + } + + @Test + fun copyHasClassicSpacesAndDirectionMark() { + val ltr = copy(sentEdited, Density(1f), LayoutDirection.Ltr) + assertEquals("\u2060\u200E" + (iconSpace + iconSpace + "\u00A0" + "\u00A0" + sentEdited.timestampText).replace(' ', '\u00A0'), ltr.text) + val rtl = copy(receivedTimed, Density(1f), LayoutDirection.Rtl) + assertEquals("\u2060\u200F" + (iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText).replace(' ', '\u00A0'), rtl.text) + val proxy = copy(sentViaProxy, Density(1f), LayoutDirection.Ltr, showViaProxy = true) + assertEquals("\u2060\u200E" + (iconSpace + "\u00A0" + iconSpace + "\u00A0" + "\u00A0" + sentViaProxy.timestampText).replace(' ', '\u00A0'), proxy.text) + } + + @Test + fun iconSpacesKeepWidthAcrossFontScales() { + val ts = sentEdited.timestampText + val widths = listOf(0.7f, 0.75f, 1f, 1.3f).associateWith { s -> + val density = Density(1f, s) + val text = copy(sentEdited, density, LayoutDirection.Ltr) + val p = paragraph(text, density, TextDirection.Ltr, 1000) + val timeStart = text.length - ts.length + assertTrue(p.getHorizontalPosition(2, true) < 0.5f, "marks at scale $s: ${p.getHorizontalPosition(2, true)} px") + val icons = p.getHorizontalPosition(timeStart, true) - p.getHorizontalPosition(2, true) + val time = p.getHorizontalPosition(text.length, true) - p.getHorizontalPosition(timeStart, true) + icons to time + } + val (icons1, time1) = widths.getValue(1f) + for ((s, w) in widths) { + assertTrue(abs(w.first - icons1) < 0.5f, "icon spaces at scale $s: ${w.first} px, at scale 1: $icons1 px") + assertTrue(abs(w.second / time1 - s) < 0.05f, "time at scale $s: ${w.second} px, at scale 1: $time1 px") + } + } + + @Test + fun ruleOnlyMessageAsWideAsCopy() { + val placed = mutableMapOf() + renderScene { + SimpleXTheme { + CompositionLocalProvider(LocalTerminalLayout provides true, LocalDensity provides Density(1f)) { + Box(Modifier.width(IntrinsicSize.Max)) { + MarkdownText( + "---", listOf(FormattedText("---")), listOf(MarkdownBlock.Rule(SegmentRange(0, 1))), meta = sentEdited, + toggleSecrets = false, linkMode = SimplexLinkMode.DESCRIPTION, blockLayout = true, modifier = Modifier.record(placed, "rule") + ) + } + } + } + } + val copyWidth = paragraph(copy(sentEdited, Density(1f), LayoutDirection.Ltr), Density(1f), TextDirection.Ltr, 1000).maxIntrinsicWidth + assertTrue(abs(placed.getValue("rule").width - copyWidth) <= 1f, "rule ${placed.getValue("rule").width} px, copy $copyWidth px") + } + + @Test + fun copyAfterSpacesStaysWithLastWord() { + val density = Density(1f) + val head = "The bot replied at once" + val text = buildAnnotatedString { + append("$head ") + append(copy(sentEdited, density, LayoutDirection.Ltr)) + } + val oneLine = paragraph(text, density, TextDirection.Ltr, 10000) + val width = (oneLine.getHorizontalPosition(head.length + 3, true) + oneLine.maxIntrinsicWidth) / 2 + val p = paragraph(text, density, TextDirection.Ltr, width.toInt()) + assertEquals(2, p.lineCount) + assertEquals(p.getLineForOffset(head.lastIndexOf(' ') + 1), p.getLineForOffset(text.length - 1)) + } + + @Test + fun copyStaysAtLineEndAfterWordInOtherDirection() { + val cases = listOf( + "שלום לכולם, זו הפסקה האחרונה של ההודעה" to LayoutDirection.Ltr, + "This is the last paragraph of the message" to LayoutDirection.Rtl, + ) + for ((head, direction) in cases) { + val density = Density(1f) + val text = buildAnnotatedString { + append(head) + append(copy(sentEdited, density, direction)) + } + val p = paragraph(text, density, if (direction == LayoutDirection.Ltr) TextDirection.Ltr else TextDirection.Rtl, 230) + val lastLine = p.lineCount - 1 + val lineStart = p.getLineStart(lastLine) + assertTrue(lineStart in 1 until head.length, "$direction: last line start $lineStart") + assertEquals(lastLine, p.getLineForOffset(text.length - 1), "$direction: time line") + val words = p.bounds(lineStart until head.length) + val time = p.bounds(head.length + 2 until text.length) + if (direction == LayoutDirection.Ltr) { + assertTrue(time.left >= words.right - 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}") + } else { + assertTrue(time.right <= words.left + 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}") + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt new file mode 100644 index 0000000000..b76d5abe39 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt @@ -0,0 +1,160 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.chat.item.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class TerminalMetaLayoutTest { + private val density = Density(1f) + private val rowWidth = 300 + private val metaWidth = 60 + private val metaHeight = 16 + private val metaBaseline = 12 + private val metaBoxWidth = metaWidth + 12 + private val gap = with(density) { TERMINAL_META_GAP.roundToPx() } + private val blockGap = with(density) { TERMINAL_BLOCK_BOTTOM_GAP.roundToPx() } + private val placed = mutableMapOf() + + private class Result(val row: Placed, val content: Placed, val meta: Placed?, val overlay: Placed?, val endButton: Placed?, val drawn: Set) + + private fun layOut( + direction: LayoutDirection = LayoutDirection.Ltr, + metaReserved: Boolean = true, + overlay: Boolean = false, + endButton: Boolean = false, + content: @Composable (Modifier) -> Unit + ): Result { + val drawn = mutableSetOf() + renderScene(direction) { + CompositionLocalProvider(LocalTerminalLayout provides true) { + Box(Modifier.width(rowWidth.dp).record(placed, "row")) { + ItemWithMeta( + meta = { Box(Modifier.record(placed, "meta").drawBehind { drawn += "meta" }.fixed(metaWidth, metaHeight, metaBaseline)) }, + overlayMeta = if (overlay) { { Box(Modifier.record(placed, "overlay").drawBehind { drawn += "overlay" }.fixed(metaWidth, metaHeight, metaBaseline)) } } else null, + metaReserved = metaReserved, + endButton = if (endButton) { { Box(Modifier.record(placed, "endButton").fixed(22, 22, null)) } } else null + ) { content(Modifier.record(placed, "content")) } + } + } + } + val row = placed.getValue("row") + return Result(Placed(0, 0, row.width, row.height), placed.getValue("content").relativeTo(row), placed["meta"]?.relativeTo(row), placed["overlay"]?.relativeTo(row), placed["endButton"]?.relativeTo(row), drawn) + } + + @Test + fun metaBaselineOnContentLastBaseline() { + val r = layOut(metaReserved = false) { Box(it.fixed(100, 40, 30)) } + assertEquals(Placed(0, 0, 100, 40), r.content) + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } + + @Test + fun contentNarrowedByMetaWithoutReserve() { + val r = layOut(metaReserved = false) { Box(it.fillMaxWidth().height(40.dp)) } + assertEquals(Placed(0, 0, rowWidth - metaBoxWidth, 40), r.content) + } + + @Test + fun metaBesideLastLineWithRoom() { + val r = layOut { Box(it.messageLastLine().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } + + @Test + fun metaUnderLastLineWithoutRoom() { + val r = layOut { Box(it.messageLastLine().fixed(260, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 40 + gap, metaWidth, metaHeight), r.meta) + assertEquals(40 + gap + metaHeight, r.row.height) + } + + @Test + fun blockGapBelowLastBlockWithMetaBeside() { + val r = layOut { Box(it.messageLastBlock().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40 + blockGap, r.row.height) + } + + @Test + fun metaAboveBottomOfContentWithoutBaseline() { + val r = layOut(metaReserved = false) { Box(it.fixed(100, 50, null)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 50 - gap - metaHeight, metaWidth, metaHeight), r.meta) + assertEquals(50, r.row.height) + } + + @Test + fun rowAtLeastAsTallAsMeta() { + val r = layOut { Box(it.fixed(100, 10, 8)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 0, metaWidth, metaHeight), r.meta) + assertEquals(metaHeight, r.row.height) + } + + @Test + fun overlayMetaAtContentEndWhenRowIsFull() { + val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 100, null)) } + assertEquals(Placed(260 - metaBoxWidth, 100 - gap - metaHeight, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + assertEquals(100, r.row.height) + } + + @Test + fun overlayMetaAtTopOfShortContent() { + val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 10, null)) } + assertEquals(Placed(260 - metaBoxWidth, 0, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + assertEquals(metaHeight, r.row.height) + } + + @Test + fun overlayMetaOnMediaUnderWiderQuote() { + val r = layOut(metaReserved = false, overlay = true) { + Column(it.width(IntrinsicSize.Max)) { + PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth = true) { + Box(Modifier.width(290.dp).height(30.dp)) + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(260.dp, 100.dp)) + } + } + } + val media = placed.getValue("media").relativeTo(placed.getValue("row")) + assertEquals(Placed(media.x + media.width - metaBoxWidth, media.y + media.height - gap - metaHeight, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + } + + @Test + fun endButtonCentredAboveTimeLine() { + val r = layOut(endButton = true) { + Column(it.fillMaxWidth()) { + Box(Modifier.fixed(80, 24, 18)) + Box(Modifier.fixed(100, 40, 30)) + } + } + assertEquals(Placed(0, 0, rowWidth - 12 - 22, 64), r.content) + assertEquals(Placed(rowWidth - metaBoxWidth, 54 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(Placed(rowWidth - 12 - 22, (54 - metaBaseline - 22) / 2, 22, 22), r.endButton) + assertEquals(64, r.row.height) + } + + @Test + fun metaBesideLastLineRightToLeft() { + val r = layOut(direction = LayoutDirection.Rtl) { Box(it.messageLastLine().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - 100, 0, 100, 40), r.content) + assertEquals(Placed(metaBoxWidth - metaWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 484b8c7f94..8654bf1006 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx4096m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index aa739ef636..dd208e89fa 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -50,6 +50,7 @@ This document provides a structured mapping between product-level concepts, thei | PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | | PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | | PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | +| PC32 | Terminal Chat Layout | [views/chat.md](views/chat.md) (Terminal Layout) | [spec/client/chat-view.md](../spec/client/chat-view.md#TerminalLayout), [spec/state.md](../spec/state.md), [spec/services/theme.md](../spec/services/theme.md#ThemeModeOverrides) | `common/.../views/chat/item/TerminalItemView.kt`, `common/.../views/chat/ChatView.kt` (`LocalTerminalLayout` provider, terminal row branch), `common/.../views/chat/item/ChatItemView.kt` (`alignEnd`, `chatItemTailEnabled`), `FramedItemView.kt` (text edge, meta), `CIEventView.kt` (`chatEventPadding`, `chatEventTextStyle`, `ChatEventWithMeta`), `CIMetaView.kt` (`terminalReserveSpaceForMeta`), `MarkdownBlocks.kt` (terminal block gaps, `headingBox`), `TextItemView.kt` (copy of the meta), `common/.../model/ChatModel.kt` (`ChatLayoutMode`, `activeChatLayout`), `common/.../ui/theme/Theme.kt` (`ThemeModeOverrides.chatLayout`), `common/.../views/usersettings/Appearance.kt` (`ChatLayoutRow`, theme previews), `common/.../views/chat/ChatInfoView.kt` (`ChatLayoutSection`); tests: `common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt` | `Types/UITheme.hs` (`ChatLayout`, `UIThemeEntityOverrides`), `Types.hs` (`GroupProfile`), `Controller.hs` (`SetGroupChatLayout`, `SetAddressSettings`) | **Legend for abbreviated paths:** - `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index d29c1d4478..72b48d07c8 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -121,7 +121,7 @@ Reconnection: Connected -> Reconnecting -> Connected | Ended 2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues. 3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way. 4. When the connection is restored, the call returns to `Connected` and the sound stops. -5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case. +5. If the connection is not restored in 30 seconds, the call ends as disconnected. If the contact's chat version is below 22 (`CALL_RECONNECT_VERSION`), the call ends as soon as the connection is lost, without reconnection. --- diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index c920e40146..49ed6b8eff 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -45,7 +45,7 @@ Rendered by `LazyColumnWithScrollBar` with pagination: | Merged items | Adjacent messages grouped with `ItemSeparation` (timestamp, large gap, date separators) | | Floating buttons | Scroll-to-bottom button with unread count | | Date separators | Date headers between messages from different days | -| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes` | +| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes`; terminal layout: background colour without the image | | Content filter | Filter messages by type via `ContentFilter` (images, files, links, etc.) | ### Message Types @@ -126,6 +126,12 @@ Shown when `selectedChatItems != null`: When `timedMessageAllowed` is true, compose bar includes a timer icon for setting message disappear time via `customDisappearingMessageTimePref`. +### Terminal Layout + +On Android and desktop, a chat with the terminal layout shows messages as full-width rows without bubbles, starting at one edge for sent and received messages, with the sender's name above each group of messages and the time at the end of the last line. The wallpaper is drawn as its colour without the image. + +A chat uses the user's choice for that chat, otherwise the layout set by the group owner or by the address the contact came from, otherwise the user's choice for the profile and then the app setting, otherwise classic. + ## Source Files | File | Path | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index 59f3fbe78a..b29a44075e 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -88,7 +88,7 @@ Common Module (commonMain) | API | [spec/api.md](api.md) | ChatController command dispatch, ~150 API functions in 11 categories, CC/CR/API types | | Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore | | Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis | -| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions | +| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions, terminal layout (TerminalItemView) | | Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags | | Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions | | Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows | @@ -104,7 +104,7 @@ Common Module (commonMain) | Category | Path | Topic | |---|---|---| | Overview | [product/README.md](../product/README.md) | Product overview, capability map, navigation map | -| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | +| Concepts | [product/concepts.md](../product/concepts.md) | 32 product concepts (PC1-PC32) mapped to docs + source | | Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | | Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | | Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index c7d6f0674f..a6e39c01b0 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt 7. [ChatInfoView](#7-chatinfoview) 8. [GroupChatInfoView](#8-groupchatinfoview) 9. [Source Files](#9-source-files) +10. [Terminal Layout](#10-terminal-layout) --- ## Executive Summary -The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the `ChatView` composable (line ~96 in `ChatView.kt`), which orchestrates a `ChatLayout` containing a reverse-scrolling `LazyColumn` of `ChatItemView` items and a `ComposeView` for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the `views/chat/item/` package. +The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L99) composable, which orchestrates a `ChatLayout` containing a reverse-scrolling `LazyColumn` of `ChatItemView` items and a `ComposeView` for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the `views/chat/item/` package. --- @@ -36,7 +37,10 @@ ChatView | | | |-- DateSeparator | | | |-- MemberNameAndRole (group received messages) | | | |-- MemberImage (group received messages) -| | | +-- ChatItemView (message type routing) +| | | |-- ChatItemView (message type routing) +| | | +-- TerminalChatItemRow (terminal layout, replaces the three lines above) +| | | |-- sender row, avatar in groups +| | | +-- ChatItemView | | |-- ChatBannerView (first item: chat profile banner) | | +-- FloatingButtons (scroll-to-bottom, unread counter) | |-- ComposeView (message composition area) @@ -56,7 +60,7 @@ ChatView ## 2. ChatView Composable -**Location:** [`ChatView.kt#L97`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L97) +**Location:** [`ChatView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L98) ```kotlin fun ChatView( @@ -84,7 +88,7 @@ fun ChatView( ### Chat Loading -On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): +On chat ID change (via [`snapshotFlow` on `chatModel.chatId.value`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L180)): 1. Marks unread chat as read (`markUnreadChatAsRead`) 2. Clears group members state @@ -95,7 +99,7 @@ On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): ### Chat Type Routing -The outer `when (chatInfo)` (line ~229) branches: +The outer [`when (chatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L274) branches: | ChatInfo Type | Behavior | |---|---| @@ -107,13 +111,13 @@ The outer `when (chatInfo)` (line ~229) branches: ## 3. Message List -**Location:** [`ChatView.kt#L1592`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1592) (`ChatItemsList` composable) +**Location:** [`ChatView.kt#L1757`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1757) (`ChatItemsList` composable) The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, meaning index 0 is the newest message at the bottom of the screen. ### Key Behaviors -- **Merged Items:** Messages are grouped via `MergedItems.create()` (line ~1653), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`. +- **Merged Items:** Messages are grouped via [`MergedItems.create()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1821), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`; `MergedItem.revealed` is true for a single item and for a revealed group row. - **Pagination:** `PreloadItems` triggers `loadMessages` with `ChatPagination.Before` (older) or `ChatPagination.Last` (newer) when the user scrolls near list boundaries. - **Scroll To Item:** `scrollToItem` lambda supports animated scrolling to a specific item ID, used by search result taps and quoted message navigation. - **Unread Marking:** `MarkItemsReadAfterDelay` composable marks newly visible received items as read after a brief delay. @@ -127,6 +131,7 @@ The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, mea - **Group received without avatar:** Indented to align with avatar-bearing messages. - **Sent messages (group or direct):** Right-aligned with larger start padding. - **Direct messages:** Symmetric padding (76dp opposite side). +- **Terminal layout:** When `LocalTerminalLayout.current` is true, all chat types render through `TerminalChatItemRow`, which wraps `ChatItemView`, instead of the branches above (see section 10). The selection offset applies to sent items too. --- @@ -134,7 +139,7 @@ The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, mea ## 4. ChatItemView -**Location:** [`item/ChatItemView.kt#L66`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L66) +**Location:** [`item/ChatItemView.kt#L70`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L70) ```kotlin fun ChatItemView( @@ -171,6 +176,37 @@ The composable routes based on `cItem.content` and `cItem.meta.itemDeleted`: Long-press or right-click opens a dropdown menu with context-sensitive actions (see section 6). +### Terminal Layout Adjustments + +When `LocalTerminalLayout.current` is true, `ChatItemView` and the item views it calls keep their content and start at one text edge, `CHAT_ITEM_TEXT_PADDING` (12dp) inside the item; messages are drawn without the bubble fill and tail: + +- Items are start-aligned for both directions ([`alignEnd`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L125)). The item click has no indication; the row draws it (section 10). `GoToItemButton` is placed as described in section 10 ([L345](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L345), [L817](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L817)). Voice items use the arrangement of received items for both directions. +- [`chatItemTailEnabled()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L1220) returns false, so `shapeStyle` draws no tail. +- `FramedItemView` has a transparent background. Headers (report, moderated, blocked, deleted, live, forwarded) and the quote have no background and start at the text edge. The quote has the bar of Markdown quotes ([`Modifier.quoteBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt#L151)), its text in `onSurface` at 70%, 15sp. The bar and the padding, `TERMINAL_BLOCK_TOP_GAP` (5.75sp) above and `TERMINAL_BLOCK_BOTTOM_GAP` (5sp) below, span the whole quote row, including an image or video thumbnail; the thumbnail has the message corner clip of media (`thumbnailClip`, `clipChatItem`, rounded by the `chatItemRoundness` preference, square at 0); the file, voice and chat link icons of the quote have no top padding. Headers: `TERMINAL_HEADER_TOP_GAP` (2.5sp) above, `TERMINAL_HEADER_BOTTOM_GAP` (1.5sp) below; the header text uses `terminalTextStyle` at its size in `secondary`, and the icon is centred on it and takes no height (`Modifier.inlineIcon()`). +- Images, videos, link previews and chat link cards start at the text edge, `TERMINAL_BLOCK_TOP_GAP` below the part above them, and keep the message corner clip ([`mediaModifier`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L56)). Link previews and chat links are cards in the quote colour ([`cardModifier`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L58)). The item itself is not clipped (`clipChatItem` of `FramedItemView` and `ChatItemView` is skipped), so headings and emoji are drawn whole. An image or video without caption, or a chat link card without text (`gapBelowBlock`, computed with `metaReserved`, which is passed to `ItemWithMeta`), has `TERMINAL_BLOCK_BOTTOM_GAP` below it, outside `ItemWithMeta`. Captions are selected by the classic conditions. +- Captions (user, D30): the text below an image, video, link preview, chat link card, voice message or file is laid out as the text of a close follow-up message, the time on it only. `PriorityLayout` measures it in the full width of the item (`limitToPriorityWidth = false`; classic: the width of the media) and places the elements at the start edge in both directions (`placeRelative`), the caption of a link preview has no width limit (classic: `DEFAULT_MAX_IMAGE_WIDTH`), and a chat link card keeps its own width (`width(IntrinsicSize.Max)`). Its gap below the media is the gap from the same media without text to a close follow-up message. An image or video without caption keeps the classic limit (`limitToPriorityWidth = true`): a quote or header above it takes the width of the media, so the white time over the media stays on its bottom end corner. +- [`CIMarkdownText`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L481): top padding `terminalTop` (required): 0 below the sender row, a header, the quote, a voice message or a file, `TERMINAL_BLOCK_BOTTOM_GAP` below an image, video, link preview or chat link card; plus [`terminalHeadingGap(item)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L121-L122), `TERMINAL_LEVEL_STEP` when the text starts with a heading and is not at the top of the item (below a quote, a header or a block); no bottom padding. Its text style: `body1` in `messageTextColor()`, merged with `terminalTextStyle` at the `body1` size. The default style of `MarkdownText` is classic and is used outside the rows (the compose context preview). +- Code block cards, table cards and rules of `MarkdownBlocks`: `TERMINAL_BLOCK_TOP_GAP` above, `TERMINAL_BLOCK_BOTTOM_GAP` below, except the last block of a message without a meta line (`marksLastBlock`; a live message has the meta line with the typing indicator, so its last block keeps the gap). That block has no bottom margin and is marked with `messageLastBlock()`: `TERMINAL_BLOCK_BOTTOM_GAP` is added below it when the time stays beside it, and the time moved under it is `TERMINAL_META_GAP` below its edge (section 10). In a message of rules only, the rules are at least as wide as the copy of the time (`ruleMinWidth` of `MarkdownBlocks`, measured by `MarkdownText`; user, D31), as in classic, where the meta line sets the width; the time stays beside them unless a wider quote or header widens the item so that the rule leaves no room for the time. +- Text blocks of [`MarkdownBlocks`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt#L59): a paragraph break, and a heading that does not start the text, add `TERMINAL_LEVEL_STEP` (4.75sp) (classic: half the line height, before a paragraph break only); above a heading that starts the text, the gap is added by `terminalRowGap` or `terminalHeadingGap`. A block after a heading, other than a heading, has `TERMINAL_HEADING_BOTTOM_GAP` (half of `TERMINAL_LEVEL_STEP`, 2.375sp) above it instead, so a heading is nearer to its own text than to the text above it. +- Text styles: [`terminalTextStyle(fontSize, color)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L78-L84) sets the line height to font size × `TERMINAL_LINE_HEIGHT_RATIO` (22/16) × f(w) and `LineHeightStyle(Proportional, Trim.None)`. Every line takes the full line height, one-line texts included. The space added to a line is split in the ratio of the font's ascent to its descent. On desktop each run of a line is given its own line box; with this alignment, runs of different sizes (the 12sp time in the copy of the meta) are placed on one baseline in a line of the body line height. f(w) = 1 − `TERMINAL_LINE_HEIGHT_CONTRAST_SLOPE` (0.1515) × (1 − w); w: the L* contrast of the text colour against `MaterialTheme.colors.background`, divided by that of `messageTextColor()`, limited to 0…1, and 1 when the message text has no contrast. Used for message text and captions (the style of `CIMarkdownText`), Markdown quotes (text colour at 70%), code, headings (body line height × scale), quote text and the quote's sender (in `body1`, Inter, in terminal rows), the sender name and role, event texts ([`chatEventTextStyle(base)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L22-L28): 12sp, `secondary`, merged into `base`, by default `LocalTextStyle.current`; classic: `base`; in both layouts the text direction is left unspecified, which Compose resolves to the layout direction, so that an event starting with a name in the opposite direction keeps the copy of the time at the end of the row), header texts and the time (classic: unchanged). For names and the white time over media (`lineHeightColor` of `CIMetaView`), f is computed with the message text colour. Line heights in the light and dark themes: message text 22sp, quote text 19.6sp and 20.1sp, event text and time 15.1sp and 15.5sp. +- Headings: body size × `TERMINAL_HEADING_RATIO` (1.3125) to the power 2, 1, 0 for levels 1, 2, 3, 27.6, 21 and 16sp for 16sp text; levels 4–6 also at the body size; all bold (classic: 1.5, 1.3, 1.15); line heights scaled the same. `Modifier.headingBox` lays a heading out with the space of a body line above its capitals and below its last baseline (Inter cap height 2048/2816 em). It uses the px ratio of the body and heading font sizes: Android scales font sizes non-linearly from font scale 1.03 and, above 1.05, sets line heights in proportion to the font size in px, so the capitals are placed as body capitals at every font scale (within 1px at 1, 1.15 and 1.3). +- Files: `TERMINAL_FILE_TOP_GAP` (2sp) above the file row, no padding below, no padding of its icon. Voice messages drawn by `FramedItemView` (with a caption, a quote or a header; `hasText = true`): the layout of a voice message drawn alone (4sp above, 6sp below, its indicator), without its own time; no padding below the item. +- Emoji-only items, also under a quote or a header: `TERMINAL_EMOJI_TOP_GAP` (4.25sp) above, `TERMINAL_EMOJI_BOTTOM_GAP` (1.5sp) below (classic: 8dp; under a quote or header 6dp above, 8dp below). Under a quote or a header the emoji is not stretched to the item width, so its reported last line is the emoji itself; `PriorityLayout` places it at the start edge in both directions. +- Item gaps: one gap above each row, [`terminalRowGap(item, older, separated, largeGap, hasSenderRow)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L118-L119), passed by `ChatItemsList` as `topGap` of the row. It is `TERMINAL_LEVEL_STEP` when an older item exists, the rows are not separated and one of these holds: the gap to the older item is large; the row shows a sender row; either item is an event (content without a terminal sender); the text of the item is at its top (text or unknown content without a file, without quote, header, deletion, live state or report) and its first block is a heading. Otherwise it is 0. `separated` (`terminalRowsSeparated`): a date separator is drawn between the two rows (the older row is revealed, a single item or a revealed group row, and its date differs from that of the oldest item of the row), or the older item is the chat banner. For a collapsed group, `older` is the item older than the group, and `largeGap` is computed against the oldest item of the group. The bottom of a row is `TERMINAL_LEVEL_STEP` when a date separator is drawn below it, 8dp for the newest row, as classic, otherwise 0 (`ChatItemBox(top, bottom)`; classic: 1dp or 4dp above, 1dp or 4dp below). Above a sender row, `TERMINAL_SENDER_TOP_GAP` (6.5sp) is added; below the name, `TERMINAL_SENDER_BOTTOM_GAP` (5.5sp). The gaps are constants in sp at the top of `TerminalItemView.kt`, converted to dp with the font scale. The bottom padding of call items (8dp), the paddings of voice messages drawn without `FramedItemView` (no caption, quote, header, deletion or live state; 4sp above, 6sp below) and the 6dp between the two rows of the forwarded-from header are classic. +- Vertical gaps, measured from the baseline (or a block's bottom edge) to the top of the tallest letter, a heading's capitals, or a block's top edge: + +``` +level 1 lines of a quote +level 2 lines of a message; messages of one sender within 60 seconds; a block against the text line or block next to it +level 3 messages of one sender 60 seconds or more apart; sender name → message or header; paragraph break; above a heading, also at the start of a message +level 4 last line of a message → next sender name; above and below an event +``` +- Event, feature and member-created items use [`chatEventPadding(vertical)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L30-L33): in terminal rows the text edge at the sides, `TERMINAL_EVENT_TOP_GAP` (8.25sp) above and `TERMINAL_EVENT_BOTTOM_GAP` (1.5sp) below, so that the capitals of the event text are where the capitals of a sender name would be and its last baseline is as far from the row end as that of a message line (classic: 6dp at the sides, `vertical` above and below, 6dp by default, 8dp for the merged feature icons). The icons of feature and preference items are centred on the text and take no height (`Modifier.inlineIcon()`); the row of merged feature icons is one event text line high. `CIEventView` has no line limit in terminal rows, so the copy of the time stays at the end of the text (user, D23; classic: 4 lines). Voice and call items and reactions start at the text edge. +- Time and status: placed at the end of the row by [`ItemWithMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L200-L214), the baseline of the time on the last baseline of the content (section 10). Message text ends with [`terminalReserveSpaceForMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt#L227-L270): a transparent copy of the meta joined to the last word by no-break spaces, in `FontWeight.Normal`. The copy starts with a word joiner (U+2060), which forbids a line break before the copy, also after the three spaces of an idle live message (`noTyping`), so the time is not moved alone to the next line. The direction mark of its paragraph follows (U+200E left-to-right, U+200F right-to-left), so that the bidirectional algorithm places the copy at the end of the line, also after words in the other direction on a wrapped last line. Its spaces and texts are appended by [`reserveSpaceForMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt#L145-L225), which has the conditions of `CIMetaText`, except that the timer is reserved for every timed message, and returns the classic string unchanged, through span parameters: icon spaces and the spaces between them are no-break spaces at the `body1` size in dp (16dp converted to sp with the density, so that they keep their width at every font scale, as the icons do; also after a heading and in event text), 18dp for each icon, wider than a 12dp icon with its spacer (15dp or 16dp); after the 17dp icons one more space is added (22.5dp against 21dp): the sent and received status icons (`tallStatusIcon`, also used by `CIMetaText`) and the via-proxy icon; the time and the timer text of a disappearing message are at 12sp. The copy includes the file encryption and signature icons (`encrypted` and `signedFileVerified` of `MarkdownText`, passed by `CIMarkdownText` from the item). When the last word and the copy do not fit in the line, the last word moves to the next line with the time. Text whose paragraph direction (`textLayoutDirection`: right-to-left when the first 50 characters of the message text hold a right-to-left character; all blocks and lines of a message take it) differs from the outer direction (`parentDirection`, passed by the blocks and by the meta line of a live message; otherwise `LocalLayoutDirection`) ends with a line break instead of the copy, so the time is placed on an added empty line. Emoji, and the last code block, table or rule of a message, report their width (`Modifier.messageLastLine()`, `Modifier.messageLastBlock()`); the time moves under them when the row leaves no room for it beside them. The meta line with the time is drawn under such a block only for live messages, for the typing indicator. File text keeps the space reserved for the time at the end of its size line. Images and videos without a caption (`captionlessMedia`) keep the meta in `secondary` beside the media when it fits in the row, otherwise the meta is drawn over the bottom end of the media in white, as in the classic layout (`overlayMeta`). Event items are wrapped in [`ChatEventWithMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L35-L48), which passes the separator and the end of the event text apart (`separator`, `ts`): in the classic layout two spaces and the time without a style, in the terminal layout three transparent no-break spaces in `chatEventStyle` and the transparent `terminalReserveSpaceForMeta` of the meta without status and edit icons, so the last word of the event text is moved to the next line with the time when they do not fit. Each view puts the separator where the classic layout has two spaces (user, D28): after the event text, or in the "Open" and "Accept" links, so the classic output is unchanged. +- Group invitations, deleted items and error items (`IntegrityErrorItemView`, `CIRcvDecryptionError`) keep their rounded coloured card. + +Both layouts: `MarkdownText` draws message text in [`messageTextColor()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt#L54), `onSurface` at 87% in dark themes. + --- ## 5. Message Types @@ -316,6 +352,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | +| `TerminalItemView.kt` | Terminal layout: `LocalTerminalLayout`, sender row rule, `TerminalChatItemRow`, `ItemWithMeta` | | `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` @@ -343,3 +380,139 @@ Key sections: group profile, group link, member list with roles, group preferenc When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `MemLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`MemRejected` is reserved for the knocking-admission flow). In `GroupMemberInfoView`, an additional "Status: rejected by relay operator" `InfoRow` appears when `groupRelay?.relayStatus == RelayStatus.RsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. The `RelayStatusIndicator` composable in `AddChannelView.kt` renders `RsRejected` with a red dot and "rejected" text, matching the `connFailed`/`removed` rendering. + +--- + + + +## 10. Terminal Layout + +**Location:** [`item/TerminalItemView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt) + +Rendering of a chat whose [`ChatInfo.activeChatLayout`](../state.md) is `ChatLayoutMode.Terminal`: full-width start-aligned rows, the sender name once per group of messages, avatars in groups only, messages without bubbles (item-level changes: section 4). Android and desktop; iOS draws the classic layout. + +### Activation + +| Step | Location | +|---|---| +| `terminal = chatInfo.activeChatLayout(user) == ChatLayoutMode.Terminal` | [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L268) | +| `LocalTerminalLayout provides terminal`, next to `LocalAppBarHandler` and `LocalSelectionManager` | [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L269-L273) | +| `drawWallpaperImage = false` of `chatViewBackgroundModifier` | [`ChatLayout` composable](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1010) | + +[`LocalTerminalLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L56) is `false` outside `ChatView` and the theme screens (below); chat item info keeps the classic look. The value applies to every `ChatView` of the chat: main view, member support chats and reports. + +Colours: the app, user and chat themes apply as in the classic layout. Wallpaper: `chatViewBackgroundModifier` fills the chat with the background colour of the wallpaper (preset or custom) and omits the image. + +### Functions + +| Function | Purpose | +|---|---| +| [`terminalTextStyle(fontSize, color)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L78-L84) | Terminal: line height and line height style of a text in the rows (section 4); classic: `TextStyle.Default`, so that merging it changes nothing | +| [`terminalItemShowsSender(item, older)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L102-L103) | Whether a row shows the sender row (sender rule below). `older`: the next older item in the list | +| [`sameSender(item, older)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L105-L113) | Private; same sender for grouping | +| [`terminalRowGap(item, older, separated, largeGap, hasSenderRow)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L118-L119) | The gap above a row (section 4, item gaps): `TERMINAL_LEVEL_STEP` or 0. Used by `ChatItemsList` and the theme preview | +| [`terminalHeadingGap(item)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L121-L122) | `TERMINAL_LEVEL_STEP` above a heading that starts the text of an item below a quote, a header or a block; added to the top padding of `CIMarkdownText` | +| [`ChatItem.hasQuoteOrHeader`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L836-L837) | A quote, a forwarded header, a deletion, the live state or a report: the item is drawn by `FramedItemView` with a quote or header above its content. Used by `ChatItemView` to choose `EmojiItemView` or `CIVoiceView` and by the heading gaps | +| [`CIContent.hasTerminalSender`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L130-L137) | Private. True for sent messages, sent deleted and moderated items, sent calls and group invitations, and received content with `showMemberName` (messages, deleted, moderated and blocked items, calls, errors, group invitations, invalid JSON); false for events, feature and preference changes, E2EE info and the chat banner | +| [`TerminalRowInteractionSource`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L139-L153) | Interaction source of the row and its item: press interactions are emitted at the position where the pointer went down in the row, so the ripple starts there | +| [`Modifier.terminalRowIndication(source)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L155-L166) | Records the pointer-down position; hover and press indication of the whole row | +| [`Modifier.terminalStartOffset(offset)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L168-L173) | Shifts the row by the selection offset and narrows it by the same amount | +| [`Modifier.messageLastLine()`, `Modifier.messageLastBlock()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L179-L187) | `messageLastLine()` sets the private alignment lines `LastLineLeft` and `LastLineRight` to the left and right edges of the element: emoji. `messageLastBlock()` also sets `LastBlockBottom` at the bottom edge of the element: the last code block, table or rule of a message without a meta line, which has no bottom margin | +| [`Modifier.inlineIcon()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L189-L198) | Terminal: lays the element out with no height, centred on its position, so that a row with `Alignment.CenterVertically` takes the height of its text and centres the element on it: icons of feature and preference items and of message headers. Classic: the modifier unchanged | +| [`ItemWithMeta(meta, overlayMeta, metaReserved, endButton, content)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L200-L214) | Classic: `content` with the meta passed as `inlineMeta`. Terminal: `content` without meta, and the meta in a box with 12dp end padding at the end of the available width, the message column of the row (private [`TerminalMetaLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L216-L256)). `endButton`, in a box with the same end padding, is placed at the end of the row, centred vertically between the content top and the top of the time box (the whole content when the time is over media); the content is measured in the width less the wider of this box and, without `metaReserved`, the time box. The meta is measured first. Without `metaReserved` and `overlayMeta`, the content is measured in the width less the width of the box, so that it does not reach under the time. `metaReserved` is true for content with its own reserve of the space or a reported last line: text with the transparent copy (`metaReserved` of `FramedItemView`), files, emoji and event items. The first baseline of the box (the baseline of the time; without a time, the baseline of a 12sp time centred in the box) is placed on the last baseline of the content. When the content reports its last line and the line and the box together are wider than the row, the box is placed `TERMINAL_META_GAP` (6sp) under the content and the bottom of the row moves down by that gap and the height of the box. When the box stays beside a last block reported with `LastBlockBottom` at the content bottom (a table, or a code card or rule, which span the width of the item), `TERMINAL_BLOCK_BOTTOM_GAP` is added below the content. Content without a baseline (a message of a rule only, a failed call), and captionless media with `overlayMeta`: the box ends `TERMINAL_META_GAP` above the content bottom; when the content and the box together are wider than the row, the `overlayMeta` box is placed at the bottom end of the content instead, `TERMINAL_META_GAP` above its bottom. Either box is placed at the top of the content at most, and the row is at least as tall as the box. When the rule leaves room for the time beside it, the time stays on the baseline of the last text line above it (D24); otherwise it is placed under the rule. | +| [`ChatItemSender`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L258) | Name, badge, role label, `own`, avatar | +| [`chatItemSender(chatInfo, cItem, range, reversedChatItems, showMemberInfo, showChatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L260-L307) | Sender of a row (table below) | +| [`SenderNameAndRole(sender, modifier)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L312-L335) | Private; name and badge (13.5sp; primary for own items, secondary otherwise) and role label | +| [`terminalGutter(chatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L337-L339) | Gutter width: the member image size (scaled by `fontSizeSqrtMultiplier`) plus 4dp in groups, 0 in other chats | +| [`TerminalChatItemRow(sender, gutter, topGap, modifier, senderTopGap, content)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L341-L383) | Row layout below | + +In `ChatView.kt`, shared with the classic date separator: + +| Function | Purpose | +|---|---| +| [`terminalRowsSeparated(olderRow)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L3817-L3820) | A date separator or the chat banner between a row and the older row: the newest item of the older row is the banner, or [`rowItemSeparation(row)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L3812-L3815) of the older row has a date. False without an older row | +| `rowItemSeparation(row)` | Private; the separation of a row, also used by `ChatItemsList` for the date separator it draws: `getItemSeparation` of the newest item of the row and the next newer item (the oldest item of the newer row) when the row is revealed (`MergedItem.revealed`: a single item or a revealed group row), without the newer item otherwise (user, D32) | + +### Row Layout + +``` +row padding top: topGap (terminalRowGap), plus senderTopGap (TERMINAL_SENDER_TOP_GAP, 6.5sp) with a sender row; start 8dp; end 12dp +gutter terminalGutter(chatInfo) + avatar top of the gutter, sender rows of groups only +message column starts at the gutter + sender row name and badge (13.5sp; primary for own messages, secondary otherwise), role label; start padding CHAT_ITEM_TEXT_PADDING; text style terminalTextStyle(13.5sp) + item ChatItemView; TERMINAL_SENDER_BOTTOM_GAP (5.5sp) below the sender row +``` + +`ChatItemsList` passes [`showAvatar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L2460) = `terminalItemShowsSender(item, merged.oldest().nextItem)`, and the row gets `chatItemSender(...)` when it is true, `null` otherwise; the [gap above the row](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L2461-L2466), with `terminalRowsSeparated` of the older row (the next merged item of the reversed list), is passed to `ChatViewListItem` as `terminalGap`. + +### Sender Rule + +`terminalItemShowsSender`: a row shows the sender row when the item has a sender (`hasTerminalSender`) and one of these holds: + +- it is the oldest loaded item; +- the older item has no sender (an event); +- `sameSender` is false. + +The time between the items and a date change do not repeat the sender row. + +`sameSender`: when either item is posted as the channel (`ChannelRcv`, or `GroupSnd` with `meta.showGroupAsSender`), both must be; two `GroupRcv` items need the same `memberId`; other items need the same direction class. + +### Sender Resolution + +| Direction | Name and badge | Role label | Avatar | Avatar click | Own colour | +|---|---|---|---|---|---| +| `ChannelRcv`, `GroupSnd` with `showGroupAsSender` | Channel name | "channel" (`channel_role_label`) | `ProfileImage` of the group, as in the classic layout | `showChatInfo` | For `GroupSnd` | +| `DirectRcv` | Contact display name and badge | None | None | None | No | +| `GroupRcv` | `memberNames` of the merged members; badge for one member | Member role when above `Member`, for one member | `MemberImage` | `showMemberInfo` | No | +| `GroupSnd` | `groupInfo.membership` display name and badge | Membership role when above `Member` | `MemberImage(membership)` | None | Yes | +| Other, incognito chat | "Incognito" | None | None | None | Yes | +| Other (`DirectSnd`, `LocalSnd`, `LocalRcv`) | Current user display name and badge | None | None | None | Yes | + +The sender row has no time; the time of every item is placed by `ItemWithMeta`. + +### Row Background + +| State | Background | +|---|---| +| Selected (selection mode) | `primary` at 10% | +| Highlighted (`highlightedItems` contains the item, e.g. after jumping to a quoted message) | `primary` at 14% | +| Otherwise | Transparent | + +Hover and press are drawn by `terminalRowIndication` over the whole row. + +### Go-to Button + +[`GoToItemButton`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L290) is activated by the row hover (`parentActivated`; the row interaction source is passed as `bubbleInteractionSource`). + +- Search results: the search icon is drawn by `ChatItemView` after the item, as for classic received items: at the end of the row, centred on the item; every row of the results is narrowed by the button and its 13dp start padding, so their times stay aligned. +- Forwarded items drawn by `FramedItemView`: the arrow is passed as `endButton` and placed by `ItemWithMeta` at the end of the row, centred on the item above the line with the time (the forwarded header line for a one-line message); the text is narrowed by the arrow, and the time keeps its place. +- Forwarded items shown as marked deleted (`markedDeleted`, no header line): the arrow after the item, as in search results. + +### Selection and Swipe + +The selection checkbox (8dp start padding) and the full-row selection click overlay are shared with the classic layout. The row is moved by `Modifier.terminalStartOffset` for sent and received items alike. Swipe to reply uses the `swipeableModifier` of the classic layout on the whole row (Android; off in selection mode). + +### Theme Screens + +`LocalTerminalLayout` is provided to the theme screens of a terminal chat or profile: + +| Screen | Value | +|---|---| +| Chat theme ([`ChatWallpaperEditorModal`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1300)) | `activeChatLayout` of the chat | +| Appearance › Themes, preset selector ([`ThemesSection`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L581)) | `profileChatLayout` of the current user, or of none for the app theme destination | +| Customize theme, profile theme, colour editors | the same, through `profileLayoutIsTerminal` and the `terminal` parameter of `editColor` | + +With the value true: + +- the preview draws two terminal rows ("alice", then "bob" in the own colour) without gutter, the gap between them by `terminalRowGap` and `TERMINAL_SENDER_TOP_GAP`, as in the chat; [`PreviewInEitherLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L337) measures both layouts and shows the active one centred in the larger height, so a layout switch keeps the preview height; +- the preview and the preset tiles show the wallpaper colour without the image; +- the custom image tile, the image scale, the scale slider and the wallpaper accent row are hidden. + +Layout controls: + +| Control | Values | Stored in | +|---|---|---| +| Chat theme screen, first section ([`ChatLayoutSection`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1313)) | the chat default, "(chat)" when the chat layout is the default, otherwise "(default)"; the other layout | chat theme record, by [`setChatLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1382) and `apiSetChatUIThemes` | +| Appearance › Themes, app theme destination ([`ChatLayoutRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L950)) | Classic, Terminal; the selection is `profileChatLayout(null)` | `appPrefs.chatLayout` | +| Appearance › Themes, profile destination | `profileChatLayout(null)` "(default)"; the other layout | profile theme record, by `saveThemeToDatabase` and `apiSetUserUIThemes` | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 6ae1e8d9f6..8c5b474dfd 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -41,6 +41,7 @@ | PC29 | User Address | | PC30 | Member Support Chat | | PC31 | Channels (Relays) | +| PC32 | Terminal Chat Layout | --- @@ -52,13 +53,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `App.kt` | PC1 through PC31 | High | Root composable — navigation scaffold for all features | +| `App.kt` | PC1 through PC32 | High | Root composable — navigation scaffold for all features | | `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | -| `model/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here | -| `model/SimpleXAPI.kt` | PC1 through PC31 | High | FFI bridge to Haskell core — all commands and responses | +| `model/ChatModel.kt` | PC1 through PC32 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC32 | High | FFI bridge to Haskell core — all commands and responses | | `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | -| `platform/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | -| `platform/AppCommon.kt` | PC1 through PC31 | Medium | Shared app initialization logic | +| `platform/Core.kt` | PC1 through PC32 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC32 | Medium | Shared app initialization logic | | `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | | `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | | `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | @@ -68,7 +69,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | | `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | | `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | -| `platform/Platform.kt` | PC1 through PC31 | Low | Platform detection and capability flags | +| `platform/Platform.kt` | PC1 through PC32 | Low | Platform detection and capability flags | | `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | | `platform/Back.kt` | PC1 | Low | Back navigation handling | | `platform/UI.kt` | PC24 | Low | UI density and locale helpers | @@ -82,7 +83,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `ui/theme/ThemeManager.kt` | PC24 | Medium | Theme resolution engine — all color and wallpaper logic | -| `ui/theme/Theme.kt` | PC24 | Medium | Theme composables and `SimpleXTheme` | +| `ui/theme/Theme.kt` | PC24, PC32 | Medium | Theme composables and `SimpleXTheme` | | `ui/theme/Color.kt` | PC24 | Low | Color palette definitions | | `ui/theme/Shape.kt` | PC24 | Low | Shape token definitions | | `ui/theme/Type.kt` | PC24 | Low | Typography definitions | @@ -108,7 +109,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC32 | High | Core conversation UI — most messaging features | | `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | | `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | | `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion | @@ -117,11 +118,11 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | | `views/chat/ContextItemView.kt` | PC6 | Low | Reply/edit quote preview | | `views/chat/SelectableChatItemToolbars.kt` | PC7, PC10 | Medium | Multi-select toolbar (delete, forward) | -| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20 | Medium | Contact details and verification | +| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20, PC32 | Medium | Contact details and verification | | `views/chat/ContactPreferences.kt` | PC2, PC8 | Medium | Per-contact feature preferences | | `views/chat/ChatItemInfoView.kt` | PC2, PC3 | Low | Message delivery detail | | `views/chat/ChatItemsLoader.kt` | PC2, PC3 | Medium | Pagination and message loading logic | -| `views/chat/ChatItemsMerger.kt` | PC2, PC3 | Medium | Merges incremental message updates | +| `views/chat/ChatItemsMerger.kt` | PC2, PC3, PC32 | Medium | Merges incremental message updates | | `views/chat/VerifyCodeView.kt` | PC13 | Medium | Contact security code verification | | `views/chat/ScanCodeView.kt` | PC13 | Low | QR code scanning for verification | | `views/chat/CommandsMenuView.kt` | PC4 | Low | Slash-command menu | @@ -134,28 +135,30 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8 | High | Root chat item renderer with context menus | -| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4 | Medium | Text message bubble rendering | -| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8, PC32 | High | Root chat item renderer with context menus | +| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4, PC32 | Medium | Text message bubble rendering | +| `views/chat/item/MarkdownBlocks.kt` | PC4, PC32 | Medium | Block Markdown rendering; terminal block gaps and headings | +| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11, PC32 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/TerminalItemView.kt` | PC2, PC3, PC32 | Medium | Terminal layout rows, sender rows, time placement | | `views/chat/item/CIImageView.kt` | PC10 | Medium | Image message rendering | | `views/chat/item/CIVideoView.kt` | PC10 | Medium | Video message rendering | -| `views/chat/item/CIFileView.kt` | PC10 | Medium | File message rendering | -| `views/chat/item/CIVoiceView.kt` | PC9 | Medium | Voice message rendering and playback | -| `views/chat/item/EmojiItemView.kt` | PC5 | Low | Emoji reaction display | -| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8 | Low | Timestamp, delivery status, timed message indicator | -| `views/chat/item/CICallItemView.kt` | PC17 | Low | Call event item rendering | -| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16 | Low | Group event item rendering | -| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14 | Low | Group invitation item rendering | -| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12 | Low | Member-created contact event | -| `views/chat/item/CIChatFeatureView.kt` | PC8 | Low | Feature change event rendering | -| `views/chat/item/CIFeaturePreferenceView.kt` | PC8 | Low | Feature preference change rendering | -| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3 | Low | Decryption error display | -| `views/chat/item/DeletedItemView.kt` | PC7 | Low | Deleted message placeholder | -| `views/chat/item/MarkedDeletedItemView.kt` | PC7 | Low | Moderated/marked-deleted placeholder | +| `views/chat/item/CIFileView.kt` | PC10, PC32 | Medium | File message rendering | +| `views/chat/item/CIVoiceView.kt` | PC9, PC32 | Medium | Voice message rendering and playback | +| `views/chat/item/EmojiItemView.kt` | PC5, PC32 | Low | Emoji reaction display | +| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8, PC32 | Low | Timestamp, delivery status, timed message indicator | +| `views/chat/item/CICallItemView.kt` | PC17, PC32 | Low | Call event item rendering | +| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16, PC32 | Low | Group event item rendering | +| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14, PC32 | Low | Group invitation item rendering | +| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12, PC32 | Low | Member-created contact event | +| `views/chat/item/CIChatFeatureView.kt` | PC8, PC32 | Low | Feature change event rendering | +| `views/chat/item/CIFeaturePreferenceView.kt` | PC8, PC32 | Low | Feature preference change rendering | +| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3, PC32 | Low | Decryption error display | +| `views/chat/item/DeletedItemView.kt` | PC7, PC32 | Low | Deleted message placeholder | +| `views/chat/item/MarkedDeletedItemView.kt` | PC7, PC32 | Low | Moderated/marked-deleted placeholder | | `views/chat/item/ImageFullScreenView.kt` | PC10 | Low | Full-screen image viewer | | `views/chat/item/CIBrokenComposableView.kt` | — | Low | Fallback for render failures | | `views/chat/item/CIInvalidJSONView.kt` | — | Low | Fallback for malformed items | -| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3 | Low | Message integrity error display | +| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3, PC32 | Low | Message integrity error display | ### 1.6 Views — Groups @@ -207,13 +210,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | -| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization | +| `views/usersettings/Appearance.kt` | PC24, PC32 | Low | Theme and appearance customization | | `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings | | `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing | | `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management | | `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access | | `views/usersettings/IncognitoView.kt` | PC20 | Low | Incognito mode explanation | -| `views/usersettings/UserAddressView.kt` | PC29 | Medium | User SimpleX address management | +| `views/usersettings/UserAddressView.kt` | PC29, PC32 | Medium | User SimpleX address management | | `views/usersettings/UserAddressLearnMore.kt` | PC29 | Low | Address educational content | | `views/usersettings/NotificationsSettingsView.kt` | PC18 | Medium | Notification mode configuration | | `views/usersettings/CallSettings.kt` | PC17 | Low | Call-related settings | @@ -271,15 +274,15 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/helpers/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features | -| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack | -| `views/helpers/Utils.kt` | PC1 through PC31 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/AlertManager.kt` | PC1 through PC32 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC32 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC32 | Low | Shared formatting, clipboard, and utility functions | | `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | | `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | | `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | | `views/helpers/ChatWallpaper.kt` | PC24 | Low | Chat wallpaper rendering | | `views/helpers/ChatInfoImage.kt` | PC19 | Low | Profile image composable | -| `views/helpers/ThemeModeEditor.kt` | PC24 | Low | Theme mode toggle | +| `views/helpers/ThemeModeEditor.kt` | PC24, PC32 | Low | Theme mode toggle | | `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker | | `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop | | `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers | @@ -326,8 +329,8 @@ Path prefix: `android/src/main/java/chat/simplex/app/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels | -| `MainActivity.kt` | PC1 through PC31 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexApp.kt` | PC1 through PC32 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC32 | High | Single-activity host — intent handling, lifecycle, deep links | | `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | | `CallService.kt` | PC17 | Medium | Foreground service for active calls | | `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | @@ -341,7 +344,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `platform/AppCommon.android.kt` | PC1 through PC31 | Medium | Android app initialization actual declarations | +| `platform/AppCommon.android.kt` | PC1 through PC32 | Medium | Android app initialization actual declarations | | `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | | `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | | `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | @@ -408,7 +411,7 @@ Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch | +| `Main.kt` | PC1 through PC32 | High | JVM entry point — Haskell init, migrations, app launch | ### 3.2 Desktop Platform Implementations (desktopMain) @@ -423,7 +426,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland | | `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | -| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | +| `platform/AppCommon.desktop.kt` | PC1 through PC32 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | | `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | | `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | @@ -487,14 +490,14 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `src/Simplex/Chat.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration | -| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here | -| `src/Simplex/Chat/Types.hs` | PC1 through PC31 | High | Core data types shared across all features | -| `src/Simplex/Chat/Core.hs` | PC1 through PC31 | High | Chat engine lifecycle (start, stop, subscribe) | -| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations | -| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing | -| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | High | Event subscriber — incoming message routing | -| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| `src/Simplex/Chat.hs` | PC1 through PC32 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC32 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC32 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC32 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC32 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC32 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC32 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC32 | High | Chat-level message protocol (x-events) | | `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | | `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | | `src/Simplex/Chat/Messages/CIContent/Events.hs` | PC3, PC14, PC16 | Medium | Group event content types | @@ -503,15 +506,15 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | | `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | | `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | -| `src/Simplex/Chat/Store.hs` | PC1 through PC31 | High | Database store interface | -| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store.hs` | PC1 through PC32 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC32 | Medium | Shared store utilities | | `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | -| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | -| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | +| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30, PC32 | High | Group persistence | +| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13, PC32 | High | Contact persistence | | `src/Simplex/Chat/Store/Files.hs` | PC10 | Medium | File transfer persistence | -| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21 | Medium | User profile persistence | -| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12 | High | Connection persistence and entity resolution | -| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12 | Medium | Contact request persistence | +| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21, PC32 | Medium | User profile persistence | +| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12, PC32 | High | Connection persistence and entity resolution | +| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12, PC32 | Medium | Contact request persistence | | `src/Simplex/Chat/Store/NoteFolders.hs` | PC1 | Low | Note folder (self-chat) persistence | | `src/Simplex/Chat/Store/Delivery.hs` | PC2, PC3 | Medium | Delivery task persistence | | `src/Simplex/Chat/Store/AppSettings.hs` | PC25 | Low | App settings persistence | @@ -525,19 +528,19 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Remote/RevHTTP.hs` | PC27 | Low | Reverse HTTP for remote desktop | | `src/Simplex/Chat/Remote/AppVersion.hs` | PC27 | Low | Remote version negotiation | | `src/Simplex/Chat/ProfileGenerator.hs` | PC20 | Low | Random profile generation for incognito | -| `src/Simplex/Chat/Types/UITheme.hs` | PC24 | Low | Theme data types for UI customization | +| `src/Simplex/Chat/Types/UITheme.hs` | PC24, PC32 | Low | Theme data types for UI customization | | `src/Simplex/Chat/Types/Preferences.hs` | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | | `src/Simplex/Chat/Types/Shared.hs` | PC3, PC16 | Medium | Shared types including GroupMemberRole | | `src/Simplex/Chat/Types/MemberRelations.hs` | PC3, PC16, PC30 | Medium | Member relationship state machine | | `src/Simplex/Chat/Operators.hs` | PC25 | Medium | Server operator management | | `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | | `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | -| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | -| `src/Simplex/Chat/Mobile.hs` | PC1 through PC31 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/AppSettings.hs` | PC25, PC32 | Low | App settings sync types | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC32 | High | C FFI exports — JNI bridge target | | `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | -| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC31 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC32 | Medium | Shared FFI helpers | | `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | -| `src/Simplex/Chat/View.hs` | PC1 through PC31 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/View.hs` | PC1 through PC32 | Low | Terminal view rendering (not used by mobile/desktop UI) | | `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | | `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | | `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index a46de4f45c..1bbcb81cad 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -54,7 +54,7 @@ enum class CallState { State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). -**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. +**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. Reconnection is enabled by `canReconnect` in `WCallCommand.Start` / `WCallCommand.Offer`, set when the contact's peer chat version is at least `CALL_RECONNECT_VERSION` (22); otherwise the call ends as soon as the connection is lost. --- diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md index e5839fc193..e278dbe2a9 100644 --- a/apps/multiplatform/spec/services/theme.md +++ b/apps/multiplatform/spec/services/theme.md @@ -139,7 +139,7 @@ Converts `Color` to `#AARRGGBB` hex string with intelligent alpha handling. ## 3. Default Themes -[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L26): +[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L27): ```kotlin enum class DefaultTheme { @@ -160,7 +160,7 @@ enum class DefaultTheme { `SYSTEM` is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime. -`DefaultThemeMode` ([line 46](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L46)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. +`DefaultThemeMode` ([line 47](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L47)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. --- @@ -168,9 +168,9 @@ enum class DefaultTheme { -### AppColors (line 53) +### AppColors (line 54) -[`Theme.kt` L53](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L53): +[`Theme.kt` L54](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L54): ```kotlin @Stable @@ -188,9 +188,9 @@ Mutable state properties (for efficient recomposition) representing chat-specifi -### AppWallpaper (line 106) +### AppWallpaper (line 107) -[`Theme.kt` L106](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L106): +[`Theme.kt` L107](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L107): ```kotlin @Stable @@ -205,7 +205,7 @@ Represents the active wallpaper state with optional background color, tint overl -### ThemeColor (line 140) +### ThemeColor (line 141) Enum of all customizable color slots: @@ -215,17 +215,17 @@ Each has a `fromColors()` method to extract the current value and a `text` prope -### ThemeColors (line 183) +### ThemeColors (line 184) -[`Theme.kt` L183](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L183): +[`Theme.kt` L184](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L184): Serializable data class with optional hex color strings for each slot. Uses `@SerialName` annotations for YAML compatibility (`accent` for `primary`, `accentVariant` for `primaryVariant`, `menus` for `surface`, etc.). -### ThemeWallpaper (line 224) +### ThemeWallpaper (line 225) -[`Theme.kt` L224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L224): +[`Theme.kt` L225](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L225): ```kotlin @Serializable @@ -248,9 +248,9 @@ Key methods: -### ThemeOverrides (line 304) +### ThemeOverrides (line 305) -[`Theme.kt` L304](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L304): +[`Theme.kt` L305](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L305): ```kotlin @Serializable @@ -269,25 +269,29 @@ A complete theme override entry. Multiple can coexist (one per wallpaper type pe -### ThemeModeOverrides (line 475) +### ThemeModeOverrides (line 476) -[`Theme.kt` L475](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L475): +[`Theme.kt` L476](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L476): ```kotlin @Serializable data class ThemeModeOverrides( val light: ThemeModeOverride? = null, val dark: ThemeModeOverride? = null, + val chatLayout: ChatLayoutMode? = null ) ``` -Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the `User` model as `uiThemes`. +Container for per-user or per-chat overrides, with separate light and dark mode variants, and the chat layout chosen for the profile or the chat (both colour modes). Stored on the `User` model as `uiThemes`. + +- `layoutOnly()`: the overrides with the colours and wallpapers removed, `null` without a layout. Used by "Reset to app theme", "Remove image" and the app theme destination of Appearance, so the layout is kept. +- `ThemeModeOverrides?.withChatLayout(chatLayout)`: the overrides with the layout set, `null` when empty. -### ThemeModeOverride (line 487) +### ThemeModeOverride (line 494) -[`Theme.kt` L487](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L487): +[`Theme.kt` L494](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L494): ```kotlin @Serializable diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 5003bb60e5..87acb64072 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -300,6 +300,18 @@ data class ChatStats( | `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | | `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | +[`ChatInfo.activeChatLayout(user)`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1876) is computed in the app, skipping `ChatLayoutMode.Unknown` values: + +``` +chat choice ChatInfo.uiThemes.chatLayout (contact or group theme record) +chat layout ChatInfo.chatLayout: groupProfile.chatLayout of a group; Contact.chatLayout (address link data, stored when the contact is prepared) +profile choice User.uiThemes.chatLayout (profileChatLayout) +app choice appPrefs.chatLayout +otherwise ChatLayoutMode.Classic +``` + +[`defaultChatLayout(user)`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1873) is the same without the chat choice. Note folders have no chat values, so the profile and app choices apply. `ChatView` reads `chatModel.currentUser`, and `profileChatLayout` reads `appPrefs.chatLayout.state`, so a change of either recomposes the open chat; the chat list is not reloaded. The chat choice is written with `apiSetChatUIThemes` (`setChatLayout`, `ChatInfoView.kt`), the profile choice with `apiSetUserUIThemes` (`ChatLayoutRow`, `Appearance.kt`), together with the colours of the same record. + ### RelayStatus (Channels) `RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`. @@ -450,6 +462,7 @@ val appPrefs: AppPreferences by lazy { AppPreferences() } |---|---|---|---| | `oneHandUI` | `Boolean` | `true` | One-hand mode | | `chatBottomBar` | `Boolean` | `true` | Bottom bar in chat | +| `chatLayout` | `ChatLayoutMode` | `Classic` | App chat layout (`activeChatLayout`) | #### Remote Access (lines 238-243) diff --git a/apps/simplex-badge-service/src/BadgeService/Group.hs b/apps/simplex-badge-service/src/BadgeService/Group.hs index 28117655ef..b072277a76 100644 --- a/apps/simplex-badge-service/src/BadgeService/Group.hs +++ b/apps/simplex-badge-service/src/BadgeService/Group.hs @@ -76,7 +76,8 @@ buildGroupProfile GroupConfig {gDisplayName, gDescription} = image = Nothing, publicGroup = Nothing, groupPreferences = Just (emptyGroupPrefs {commands = Just groupCommands} :: GroupPreferences), - memberAdmission = Nothing + memberAdmission = Nothing, + chatLayout = Nothing } ensureManagedGroup :: ChatController -> GroupConfig -> IO (Maybe GroupId) diff --git a/apps/simplex-badge-service/src/BadgeService/Log.hs b/apps/simplex-badge-service/src/BadgeService/Log.hs index a2f54f4420..6ac7cd48a2 100644 --- a/apps/simplex-badge-service/src/BadgeService/Log.hs +++ b/apps/simplex-badge-service/src/BadgeService/Log.hs @@ -9,9 +9,10 @@ module BadgeService.Log where import Control.Monad.IO.Class (MonadIO, liftIO) +import qualified Data.ByteString as B import Data.Text (Text) import qualified Data.Text as T -import qualified Data.Text.IO as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (getZonedTime) import GHC.Stack (CallStack, HasCallStack, callStack, getCallStack, srcLocFile, srcLocStartLine) @@ -29,7 +30,8 @@ logError = logAt "ERROR" callStack logAt :: MonadIO m => Text -> CallStack -> Text -> m () logAt tag cs msg = liftIO $ do ts <- formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S %z" <$> getZonedTime - T.hPutStrLn stderr $ "[" <> tag <> " " <> T.pack ts <> " " <> loc <> "] " <> msg + -- one write per line, putStrLn family is not atomic + B.hPut stderr $ encodeUtf8 $ "[" <> tag <> " " <> T.pack ts <> " " <> loc <> "] " <> msg <> "\n" where loc = case getCallStack cs of ((_, l) : _) -> T.pack (srcLocFile l) <> ":" <> T.pack (show (srcLocStartLine l)) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 14ec531e13..897c540678 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -50,6 +50,7 @@ This file is generated automatically. - [ChatInfo](#chatinfo) - [ChatItem](#chatitem) - [ChatItemDeletion](#chatitemdeletion) +- [ChatLayout](#chatlayout) - [ChatListQuery](#chatlistquery) - [ChatPeerType](#chatpeertype) - [ChatRef](#chatref) @@ -273,6 +274,7 @@ This file is generated automatically. **Record type**: - businessAddress: bool - autoAccept: [AutoAccept](#autoaccept)? +- chatLayout: [ChatLayout](#chatlayout)? - autoReply: [MsgContent](#msgcontent)? @@ -1590,6 +1592,15 @@ Message deletion result. - toChatItem: [AChatItem](#achatitem)? +--- + +## ChatLayout + +**Enum type**: +- "classic" +- "terminal" + + --- ## ChatListQuery @@ -1981,6 +1992,7 @@ Error: - chatTags: [int64] - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +- chatLayout: [ChatLayout](#chatlayout)? - chatDeleted: bool - customData: JSONObject? @@ -2023,6 +2035,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- chatLayout: [ChatLayout](#chatlayout)? - localBadge: [LocalBadge](#localbadge)? @@ -2788,6 +2801,7 @@ UpdateRequired: - publicGroup: [PublicGroupProfile](#publicgroupprofile)? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +- chatLayout: [ChatLayout](#chatlayout)? --- @@ -4645,6 +4659,7 @@ Handshake: **Record type**: - light: [UIThemeEntityOverride](#uithemeentityoverride)? - dark: [UIThemeEntityOverride](#uithemeentityoverride)? +- chatLayout: [ChatLayout](#chatlayout)? --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 21cffa6412..fe81b430b4 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -299,6 +299,7 @@ cliCommands = "SetClientService", "SetContactFeature", "SetContactTimedMessages", + "SetGroupChatLayout", "SetGroupFeature", "SetGroupFeatureRole", "SetGroupMemberAdmissionReview", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ab76cc6581..26553b58cb 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -236,6 +236,7 @@ chatTypesDocsData = (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), + (sti @ChatLayout, STEnum, "CL", ["CLUnknown"], "", ""), (sti @ChatPeerType, STEnum, "CPT", ["CPTUnknown"], "", ""), (sti @ChatRef, STRecord, "", [], Param "chatType" <> Param "chatId" <> Optional "" (Param "$0") "chatScope", "Used in API commands. Chat scope can only be passed with groups."), (sti @ChatSettings, STRecord, "", [], "", ""), @@ -474,6 +475,7 @@ deriving instance Generic ChatError deriving instance Generic ChatErrorType deriving instance Generic ChatFeature deriving instance Generic ChatItemDeletion +deriving instance Generic ChatLayout deriving instance Generic ChatPeerType deriving instance Generic ChatRef deriving instance Generic ChatSettings diff --git a/cabal.project b/cabal.project index 746a09c590..e2e0c01f3f 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8bc4d3601250d9f79dc7e80ead319ec2b6d4a95a + tag: 381828400710344bc1c6ce94e7cdf6e0ca043ee9 source-repository-package type: git @@ -70,5 +70,5 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/wai.git - tag: 2f6e5aa5f05ba9140ac99e195ee647b4f7d926b0 + tag: 63667cb577d09c967ddcd945e42c324872dfeb7d subdir: warp diff --git a/docs/protocol/agent-protocols.md b/docs/protocol/agent-protocols.md index d4e828d677..b6048c3896 100644 --- a/docs/protocol/agent-protocols.md +++ b/docs/protocol/agent-protocols.md @@ -1,4 +1,4 @@ -Revision 1, 2026-10-19, draft for comments +Revision 2, 2026-10-21, draft for comments Evgeny Poberezkin @@ -28,6 +28,16 @@ Evgeny Poberezkin - [Messages](#messages) - [Versions and capabilities](#versions-and-capabilities) - [Examples](#examples) +- [A2A binding](#a2a-binding) + - [Identification](#identification) + - [A2A messages](#a2a-messages) + - [Service parameters](#service-parameters) + - [Streaming](#streaming) + - [Push notifications](#push-notifications) + - [Callers and authorization](#callers-and-authorization) + - [Service RPC](#service-rpc) + - [Agent Card](#agent-card) + - [A2A examples](#a2a-examples) - [Security considerations](#security-considerations) - [Privacy considerations](#privacy-considerations) - [Implementations](#implementations) @@ -41,7 +51,7 @@ This document defines how software agents find SimpleX addresses and call the to - declarations: the protocols a profile serves and their transports, the peer types a channel or a contact address is made for, and the update frequency of a channel; - the chat transport: the command `/_call` in a conversation; - the Service RPC transport: one request and one response, without a conversation, in a generic envelope; -- the binding of the Model Context Protocol (MCP) [MCP] to both transports. +- the bindings of the Model Context Protocol (MCP) [MCP] and of the Agent2Agent protocol (A2A) [A2A] to both transports. ## Conventions @@ -113,7 +123,7 @@ For SimpleX entries, the type `updates` requires a channel link, the type `group - [Acme news](https://smp12.simplex.im/c#…): SimpleX updates for people and agents, weekly. Product news and offers. - [Acme customers](https://smp12.simplex.im/g#…): SimpleX group for people. Questions and tips from customers. -- [Ask Acme](https://smp12.simplex.im/a#…): SimpleX business contact for agents, MCP. Orders and availability. +- [Ask Acme](https://smp12.simplex.im/a#…): SimpleX business contact for agents, MCP, A2A. Orders and availability. - [Acme support](mailto:support@acme.example): Email contact for people. ``` @@ -165,6 +175,10 @@ Example, the `preferences` of a contact profile: "mcp": { "versions": ["2026-07-28"], "transports": {"chat": true, "rpc": true} + }, + "a2a": { + "versions": ["1.0"], + "transports": {"chat": true, "rpc": true} } } } @@ -211,6 +225,7 @@ A binding defines the protocol's request and response messages, including how, a | Name | Binding | |---|---| | `mcp` | [MCP binding](#mcp-binding) | +| `a2a` | [A2A binding](#a2a-binding) | ## Chat transport @@ -230,10 +245,12 @@ A binding defines the protocol's request and response messages, including how, a ### Replies -- A bot sends one reply to each command it answers. The reply quotes the command, with a truncated quote. Its text is the protocol's response message, a JSON object. +- A bot answers a command with one or more replies. Each reply quotes the command, with a truncated quote. Its text is a message of the protocol, a JSON object. +- The binding defines the messages a bot sends before its last reply, and the message that ends its answer. - A caller matches each reply to its command by the quoted message. Replies can arrive in any order. -- In a group, a broadcast can receive several replies, one from each member that serves the protocol. A caller collects them all, each with its sender. -- In a direct chat, a caller accepts replies only from the recipient. In a group, a caller of a command with a mention accepts only the mentioned member's reply. +- A caller collects every reply until its wait ends, each with its sender, in arrival order. In a group, a broadcast receives the answers of every member that serves the protocol. +- A reply whose text is not a message of the protocol is collected as text, with its sender. +- In a direct chat, a caller accepts replies only from the recipient. In a group, a caller of a command with a mention accepts only the mentioned member's replies. - Commands and replies are ordinary messages: people see them in the conversation. ### Message size @@ -316,7 +333,7 @@ JSON Type Definition schema [RFC8927] of the response: ``` - A response has exactly one of `response` and `error`. -- `response`: the protocol's response message, the same as the reply in a conversation. +- `response`: the protocol's response message, the same as the last reply of the answer in a conversation. - `error`: the request was not passed to the protocol. - `message`: a description for the requester's logs. - `retryAfter`: the number of seconds after which a repeated request can succeed. @@ -347,9 +364,10 @@ Protocol name: `mcp`. ### Messages - The request is one JSON-RPC request of MCP revision 2026-07-28 or later, with its `_meta` envelope [MCP]. In a conversation, it follows `/_call mcp`. Over Service RPC, it is the envelope's `request`. -- The response is the JSON-RPC response to the request: a result or a JSON-RPC error. In a conversation, it is the text of the reply. Over Service RPC, it is the envelope's `response`. +- The bot's answer ends with the JSON-RPC response to the request: a result or a JSON-RPC error. In a conversation, it is the text of the last reply. Over Service RPC, it is the envelope's `response`. +- In a conversation, the bot MAY send notifications for the request before the response, each as a reply: for example `notifications/progress` when the request has a progress token [MCP]. - In a conversation, a caller matches a reply to its request by the quoted message. The response has the request's `id`, as JSON-RPC requires. -- A bot serves the methods answered with one response, for example `server/discover`, `tools/list` and `tools/call`. +- A bot serves the request-response methods, for example `server/discover`, `tools/list` and `tools/call`. - A multi-round-trip result (`resultType: "input_required"`) is answered with a new request, as MCP defines. - A response above the size limit is replaced with a JSON-RPC error with code -32603 in a conversation, and with the envelope error `too_large` over Service RPC. - Over Service RPC, a call of a tool that needs a conversation is answered with the envelope error `conversation_required`. @@ -431,19 +449,142 @@ The response: } ``` +## A2A binding + +Protocol name: `a2a`. This binding is a custom protocol binding of A2A 1.0 [A2A, sections 5.8 and 12]: it carries the messages of the A2A JSON-RPC binding [A2A, section 9] over both SimpleX transports. + +### Identification + +- Binding URI: `https://simplex.chat/protocol/a2a/v1`. A revision of this binding that breaks compatibility uses a new URI. +- An A2A Agent Card lists a SimpleX interface in `supportedInterfaces`, with `url` the bot's contact address link, `protocolBinding` the binding URI, and `protocolVersion` the A2A version. +- `versions` in the `protocols` declaration: A2A versions in the `Major.Minor` form, for example `1.0`. + +### A2A messages + +- The request is one JSON-RPC request of the A2A JSON-RPC binding, with its method name and parameters unchanged: `SendMessage`, `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, the push notification methods and `GetExtendedAgentCard` [A2A, section 9.4]. In a conversation, it follows `/_call a2a`. Over Service RPC, it is the envelope's `request`. +- Responses and errors are those of the A2A JSON-RPC binding, with the JSON forms of its data types: timestamps, bytes in base64 and enumerations [A2A, section 12.2]. +- For every method other than the two streaming methods, the bot's answer is one JSON-RPC response. In a conversation, it is one reply. Over Service RPC, it is the envelope's `response`. +- A response above the size limit is replaced with a JSON-RPC error with code -32603 in a conversation, and with the envelope error `too_large` over Service RPC. The bytes of `raw` parts count towards the limit. + +### Service parameters + +A2A sends service parameters, such as `A2A-Version` and `A2A-Extensions`, as HTTP headers [A2A, section 9.2]. SimpleX messages have no headers, so this binding uses the field that A2A names for protocols without headers [A2A, section 12.3]: + +- `a2a-service-parameters`: a member of the JSON-RPC request object, beside `jsonrpc`, `method`, `params` and `id`. Its value is an object of service parameter names and string values. +- Names are case-insensitive. Several values of one parameter are joined by commas in one string, as in an HTTP header. +- Every request MUST carry `A2A-Version`. A bot answers a request of a version it does not serve with A2A's `VersionNotSupportedError`. +- A proxy to an HTTP interface sends each service parameter as an HTTP header and removes the member from the request. +- The envelope's `version` is omitted. + +### Streaming + +`SendStreamingMessage` and `SubscribeToTask` are served in a conversation [A2A, section 12.5]: + +- Each `StreamResponse` is one reply that quotes the command. Its text is a JSON-RPC response with the request's `id`, whose `result` is the `StreamResponse`, as in the events of the A2A JSON-RPC binding. +- Replies are sent in the order of the stream. +- The stream ends with the reply whose task or status update is in a terminal state (completed, failed, canceled, rejected) or an interrupted state (input required, auth required). +- A caller that missed replies resumes the stream with `SubscribeToTask`. +- Over Service RPC, both methods are answered with the envelope error `conversation_required`. + +### Push notifications + +A conversation delivers every update of a task to the caller, so push notification configurations are not used. A bot answers the push notification methods with A2A's `PushNotificationNotSupportedError`. + +### Callers and authorization + +- In a conversation, the caller is the contact or the group member that sent the command [A2A, section 12.6]. +- A context and its tasks are available only to their caller: in the conversation where they were created, and in a group to the member who created them. A request that names the context or the task of another caller is answered as a request for an unknown task. +- An agent whose Agent Card declares security schemes receives the credential as the service parameter of the scheme, for example `Authorization` for an HTTP scheme. A challenge is answered with the task state `auth required`. +- A request with a credential MUST NOT be sent in a group. + +### Service RPC + +Service RPC serves one-shot tasks: a request and its answer, without a conversation. + +- A bot answers `SendMessage` whose message has neither `contextId` nor `taskId`, and whose `configuration.returnImmediately` is absent or false. +- The response is the task in its state at the end of the work, or a message. +- A bot that cannot finish the task within 180 seconds answers with the envelope error `conversation_required`. +- Every other request is answered with the envelope error `conversation_required`. + +### Agent Card + +- The Agent Card of an agent with a website is published at the well-known URI of A2A [A2A, section 8.2], with the SimpleX interface among its interfaces. +- A bot answers `GetExtendedAgentCard` with its Agent Card. For a caller without credentials, it is the public card. + +### A2A examples + +A one-shot task over Service RPC, the request: + +```json +{ + "protocol": "a2a", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "a2a-service-parameters": {"A2A-Version": "1.0"}, + "params": { + "message": { + "messageId": "m-1", + "role": "ROLE_USER", + "parts": [{"text": "Price of two anvils delivered to Lisbon"}] + } + } + } +} +``` + +The response: + +```json +{ + "response": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "task": { + "id": "t-1", + "contextId": "c-1", + "status": {"state": "TASK_STATE_COMPLETED"}, + "artifacts": [ + {"artifactId": "a-1", "parts": [{"text": "EUR 480, delivered within 5 days."}]} + ] + } + } + } +} +``` + +A streamed task in a conversation, continuing the context `c-7` created earlier in the same chat: + +``` +/_call a2a {"jsonrpc": "2.0", "id": 2, "method": "SendStreamingMessage", "a2a-service-parameters": {"A2A-Version": "1.0"}, "params": {"message": {"messageId": "m-2", "contextId": "c-7", "role": "ROLE_USER", "parts": [{"text": "Order two, delivered by Friday"}]}}} +``` + +The replies, each quoting the command: + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"statusUpdate": {"taskId": "t-8", "contextId": "c-7", "status": {"state": "TASK_STATE_WORKING"}}}} +``` + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"task": {"id": "t-8", "contextId": "c-7", "status": {"state": "TASK_STATE_COMPLETED"}, "artifacts": [{"artifactId": "a-8", "parts": [{"text": "Order 1042 confirmed, delivery on Friday."}]}]}}} +``` + ## Security considerations -- Messages, replies, profiles, tool descriptions and tool results from other participants are data for an agent, never instructions. +- Messages, replies, profiles, tool descriptions, Agent Cards, tool results and task artifacts from other participants are data for an agent, never instructions. - In a group, any member can reply to a broadcast. Each reply shows its sender. A caller that needs one provider mentions it. - A responder that relies on `requestKey` MUST compare it with `signerKey`. - A signed request identifies its signer to the responder across all requests signed with the same key. +- Every member of a group sees the commands and replies in it, so A2A credentials are sent only in a direct chat or over Service RPC. ## Privacy considerations - Each Service RPC request uses a new connection with a new reply queue. - MCP's `io.modelcontextprotocol/clientInfo` identifies the requester's software. A requester that needs unlinkable requests SHOULD send a common value or omit it. - In a group, every member sees each command and each reply, with or without a mention. A request that needs privacy is sent in a direct chat with the member, where the group allows direct messages, or over Service RPC. -- A conversation persists: the bot can link all calls made in it. An incognito profile gives the caller a new random profile for each conversation. +- A conversation persists: the bot can link all calls made in it, and all A2A contexts of its caller. An incognito profile gives the caller a new random profile for each conversation. ## Implementations @@ -455,11 +596,13 @@ Implemented: Planned: - The simplex-chat core: the key `protocols` in the preferences types, in the preferences sent in profiles and in link data, and in the preferences of business chats; `audience` and `changeFrequency` in the public group record; `serves` in the address settings and the address link data; replies with a truncated quote. -- `@simplex-chat/mcp`: the tools `mcp_tools` and `mcp_call`. They use Service RPC when the address declares `transports.rpc`, and the agent's conversation otherwise. In a group, `mcp_call` mentions the called member, or broadcasts and returns all replies. Each call is an action, approved by the agent's owner as a message is. -- The bot library: a helper that serves MCP tools over both transports. +- `@simplex-chat/mcp`: the tools `mcp_tools` and `mcp_call`, and A2A calls. They use Service RPC when the address declares `transports.rpc` and the request suits it, and the agent's conversation otherwise. In a group, a call mentions the called member, or broadcasts and returns all replies with their senders. Each call is an action, approved by the agent's owner as a message is. +- The bot library: helpers that serve MCP tools and A2A over both transports. +- An A2A proxy bot: it serves `a2a` over both transports and forwards each request to the HTTP JSON-RPC interface of an A2A agent, with the service parameters as HTTP headers. ## References +- [A2A] Agent2Agent (A2A) protocol specification, version 1.0: https://a2a-protocol.org/latest/specification/ - [BUSINESS] Using SimpleX Chat in business: [BUSINESS.md](../BUSINESS.md) - [CHAT] SimpleX Chat protocol: [simplex-chat.md](./simplex-chat.md) - [LLMSTXT] The /llms.txt file: https://llmstxt.org diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index a95e7ee75f..9400d15b68 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -264,6 +264,8 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.info` message is sent to all members by the member who updated group profile. Only group owners can update group profiles. Clients MAY implement some conflict resolution strategy - it is currently not implemented by SimpleX Chat client. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. +Group profile can include optional `chatLayout` property (`classic` or `terminal`) set by a group owner. Clients use it as the layout of the group chat, also after it is changed by `x.grp.info`, unless the user chose another layout for this chat. Clients MUST treat unknown values as absent. + `x.grp.direct.inv` message is sent to a group member to propose establishing a direct connection between members, thus creating a contact with another member. `x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. This message MUST NOT contain another `x.grp.msg.forward` message. @@ -286,7 +288,7 @@ These message are used for WebRTC calls: 3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message. -4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients that do not support it ignore this property, and the call ends when reconnection times out. +4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients restart ICE only when the peer supports chat protocol version 22 or later, otherwise the call ends when the connection is lost. 5. `x.call.end` message is sent to notify the other party that the call is terminated. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index a9bb2d8735..095e7207be 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -61,6 +61,7 @@ "type": "string", "metadata": {"comment": "group purpose / short description (160 characters)"} }, + "chatLayout": {"enum": ["classic", "terminal"]}, "groupPreferences": { "optionalProperties": { "commands": { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 873679ad50..8514cbd43c 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -25,6 +25,7 @@ export interface AddRelayResult { export interface AddressSettings { businessAddress: boolean autoAccept?: AutoAccept + chatLayout?: ChatLayout autoReply?: MsgContent } @@ -1886,6 +1887,11 @@ export interface ChatItemDeletion { toChatItem?: AChatItem } +export enum ChatLayout { + Classic = "classic", + Terminal = "terminal", +} + export type ChatListQuery = ChatListQuery.Filters | ChatListQuery.Search export namespace ChatListQuery { @@ -2296,6 +2302,7 @@ export interface Contact { chatTags: number[] // int64 chatItemTTL?: number // int64 uiThemes?: UIThemeEntityOverrides + chatLayout?: ChatLayout chatDeleted: boolean customData?: object } @@ -2355,6 +2362,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + chatLayout?: ChatLayout localBadge?: LocalBadge } @@ -3120,6 +3128,7 @@ export interface GroupProfile { publicGroup?: PublicGroupProfile groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission + chatLayout?: ChatLayout } export interface GroupRelay { @@ -5415,6 +5424,7 @@ export interface UIThemeEntityOverride { export interface UIThemeEntityOverrides { light?: UIThemeEntityOverride dark?: UIThemeEntityOverride + chatLayout?: ChatLayout } export interface UpdatedMessage { diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 67e470e52d..403ad60b46 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -104,18 +104,32 @@ export class ChatClient { } async enableAddressAutoAccept(userId: number, autoReply?: T.MsgContent, businessAddress = false): Promise { - const r = await this.sendChatCmd( - CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress, autoAccept: {acceptIncognito: false}, autoReply}}) - ) - if (r.type !== "userContactLinkUpdated") { - throw new ChatCommandError("error changing user contact address mode", r) - } + const settings = await this.apiGetAddressSettings(userId) + await this.apiSetAddressSettings(userId, {...settings, businessAddress, autoAccept: {acceptIncognito: false}, autoReply}) } async disableAddressAutoAccept(userId: number, businessAddress = false): Promise { - const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress}})) + const settings = await this.apiGetAddressSettings(userId) + await this.apiSetAddressSettings(userId, {...settings, businessAddress, autoAccept: undefined, autoReply: undefined}) + } + + async apiGetAddressSettings(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIShowMyAddress.cmdString({userId})) + switch (r.type) { + case "userContactLink": + return r.contactLink.addressSettings + default: + if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") { + return undefined + } + throw new ChatCommandError("error loading user address", r) + } + } + + async apiSetAddressSettings(userId: number, settings: T.AddressSettings): Promise { + const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { - throw new ChatCommandError("error changing user contact address mode", r) + throw new ChatCommandError("error changing user contact address settings", r) } } diff --git a/packages/simplex-chat-mcp/PLAN.md b/packages/simplex-chat-mcp/PLAN.md index 841b76f2b3..22bcf02ddd 100644 --- a/packages/simplex-chat-mcp/PLAN.md +++ b/packages/simplex-chat-mcp/PLAN.md @@ -104,6 +104,7 @@ layer who what listener http.ts 127.0.0.1:PORT; Node limits lifted for hours-long calls: requestTimeout 0, timeout 0, headersTimeout 60 s, keepAliveTimeout 5 s bearer token http.ts, before the SDK Authorization: Bearer , compared in constant time; otherwise 401 path http.ts /mcp only; otherwise 404, a request target that is not a URL included +start http.ts until the owner setup at start ends: 503 with Retry-After: 1 Origin, Host SDK middleware localhostOriginValidation (no Origin, or a localhost origin on any port), localhostHostValidation; otherwise 403 protocol SDK both eras, header and version errors, 405, body limit, cache hints (ttlMs, cacheScope), JSON or SSE per call, SSE keep-alive cancellation SDK the client closing the response stream aborts ctx.mcpReq.signal (toNodeHandler ties the Request signal to res "close") @@ -214,7 +215,7 @@ NAME not ^[a-z0-9](-?[a-z0-9])*-?$, NAME: 1 to 32 lowercase letters, digits an $ npx -y @simplex-chat/mcp serve my-agent --display-name "My Agent" Downloading libsimplex from https://github.com/simplex-chat/simplex-chat-libs/releases/download/core-7.1.0.12/simplex-chat-libs-linux-x86_64.zip ... Downloading the Node.js add-on from https://github.com/simplex-chat/simplex-chat-libs/releases/download/... ... -SimpleX MCP server 0.1.0, agent "my-agent" +SimpleX MCP server 0.1.1, agent "my-agent" Data folder: /home/alice/.simplex-mcp/my-agent Approval mode: requests Agent control chat "My Agent" created. @@ -231,7 +232,7 @@ Listening on http://127.0.0.1:5226/mcp - large: two spaces with the module's background per module, one line per module: 74 columns × 37 lines. - The download lines are written to stderr by the `simplex-chat` package on first use (`libs.ts`, `installLibs`, `installAddon`). - `npx -y` answers npm's install prompt only; the server asks no questions. -- A second `serve` on the same data folder exits with code 1: `Another server uses /home/alice/.simplex-mcp/my-agent (PID 1234).` A port in use: exit code 1, `Port 5226 is in use. Start with --port N.` +- A second `serve` on the same data folder exits with code 1: `Another server uses /home/alice/.simplex-mcp/my-agent (PID 1234). If no server runs, remove /home/alice/.simplex-mcp/my-agent/lock.` A port in use: exit code 1, `Port 5226 is in use. Start with --port N.` ### Owner joins (no --owners) @@ -285,12 +286,13 @@ action created Action 1: connect to bot SimpleX Directory, as i action decided Action 2 approved by Alice: sent. | Action 1 approved by Alice: connected. | Action 5 approved by Alice: connection requested. | Action 2 approved by Alice: failed: . action decided Action 2 rejected by Alice. | Action 2 expired. mode host Action 2: performed without approval (mode host). | ...; failed: . +counter or card not written Action 2: failed: . owner added, --owners LIST Bob (member 7) is now an owner and can't approve actions yet. Restart the server with --owners 3:Alice,7:Bob owner gone, --owners LIST Alice (member 3) is no longer an owner. Restart the server with --owners 7:Bob (the human owners remaining) no human owner remains Alice (member 3) is no longer an owner. No owners remain: restart the server with --reset-owners --owners no approver, no --owners No owners in the agent control chat. (followed by a new link) control chat lost The agent lost its control chat. Restart the server with --reset-owners. -shutdown Cancelled 2 actions. Stopped. (the count only when actions were pending or performing) +shutdown Cancelled 2 actions. Stopped. (the count only when actions were pending or performing; a perform interrupted by the stop logs no line) ``` ## What the owner sees: the agent control chat @@ -319,7 +321,7 @@ contact | bot | agent | business | group | channel plan type; channel when gro SimpleX Directory the contact's or group's display name from the link (@directory.simplex) the target, when it is a SimpleX name as myself | incognito input.incognito -quoted lines short description and welcome message from the link +quoted lines short description and welcome message from the link, cut to fit the card (section Card size) "With the message:" and the message lines with message; after the quoted lines ``` @@ -417,6 +419,7 @@ N unknown, decided or from an earlier run Action 9 is not pending. CHAT not the action's chat Action 10 is not for #3. /approve CHAT all Approved for business Ridgeline (#9) by Alice. /'revoke #9' /approve CHAT all, CHAT not the agent's No chat #13. /list shows the agent's chats. +/approve CHAT all, mode host Approvals apply in mode requests; this server runs in mode host. /revoke CHAT Messages to business Ridgeline (#9) need approval again. /revoke CHAT, no standing approval Messages to #9 have no standing approval. /list, no chats No chats. @@ -424,7 +427,7 @@ other one-line text starting with "/" Commands: /approve, /reject, /revoke ``` - CHAT is optional in `/approve N` and `/reject N` as typed; the cards include it for sends and leaves. -- A standing approval covers sends only. It ends at `/revoke`, when the chat is deleted, or when member `approvedById` is no longer an approver. It applies in mode `requests` only: in mode `host`, `list` and `/list` show no approvals. +- A standing approval covers sends only. It ends at `/revoke`, when the chat is deleted, or when member `approvedById` is no longer an approver. It applies in mode `requests` only: in mode `host`, `list` and `/list` show no approvals, and `/approve CHAT all` stores none. ### Membership messages @@ -608,7 +611,9 @@ no approver, no --owners The agent has no owner. The person who runs i no approver, --owners The agent has no approving owner. The person who runs it has to restart it with the --owners options it printed. control chat lost The agent lost its control chat. The person who runs it has to restart it with --reset-owners. 20 actions pending 20 actions are pending; wait for the owner's decisions. +20 actions performing 20 actions are being performed; wait for their results. card over 15000 bytes (send) text: too long for its approval card; shorten it. +card over 15000 bytes (connect) message: too long for its approval card; shorten it. unknown chat No chat #13. list shows the agent's chats. not a link or name Not a SimpleX link or name: link not joinable can't be joined now: . @@ -616,7 +621,9 @@ message not for an address message: is not a contact or busines chat not connected (send) The agent is not connected to @2 and can't post; list shows connected: false. role below author The agent has role observer in #12 and can't post. reply_to not in chat reply_to: #15 has no item 999. +reply_to not a message reply_to: item 31 in #15 is not a message. mention not a member mention: #15 has no member 45. (in a contact chat: mention: @4 is a contact chat; mentions apply to groups and channels.) +mention of equal names mention: members 44 and 45 have the same name. SimpleX error worded by the apps the apps' text (table below), for example: Name not found: This SimpleX name is not registered. Please check the name. other SimpleX error SimpleX error: : for example: SimpleX error: error: groupUserRole: {"requiredRole":"author"} @@ -691,7 +698,6 @@ serve(NAME, options): create DIR, mode 0700 (set again when DIR exists, except on Windows); DIR/lock created exclusively, or exit 1 when its PID runs; token = DIR/token, created when missing or with --new-token; DIR/headers.json written with the token chat = ChatApi.init({type: "sqlite", filePrefix: DIR/simplex}) profile = the user, or created: /_create user {profile: {displayName: <--display-name or NAME>, fullName: "", peerType: agent}, pastTimestamp: false} - write DIR/server.json {name: NAME, port, expireHours} /network socks=off smp-proxy=always smp-proxy-fallback=protected # the apps' defaults, SimpleXAPI.kt:5427-5447 buffer events; chat.startChat() --display-name differs from profile.displayName, or peerType is not agent: /_profile {the profile with displayName and peerType agent} # APIUpdateProfile, parser Commands.hs:6399; before startChat the core returns chatNotStarted @@ -703,6 +709,8 @@ serve(NAME, options): lost unset, group.groupProfile.displayName differs from the profile's, or its commands from the menu of createControlChat: /_group_profile #group {the group profile with that displayName and menu} # APIUpdateGroupProfile, parser Commands.hs:6534 members = /_members #group, so approvers are known before the first call HTTP listen 127.0.0.1:PORT; port in use: chat.close(); remove DIR/lock; exit 1 "Port PORT is in use. Start with --port N." # before any link or QR code is printed + requests to /mcp with the token get 503 with Retry-After: 1 until ownerStart ends + write DIR/server.json {name: NAME, port, expireHours} lost unset: ownerStart(group) log "Listening on http://127.0.0.1:PORT/mcp" every 60 s, and at each owner command: expire the pending actions whose expires has passed (wall clock, so a sleep of the computer delays no expiry); @@ -714,12 +722,14 @@ SIGINT, SIGTERM (process.on; the second signal joins the stop in progress): close HTTP (open calls end); stop handling events, so no /approve starts a perform wait for the cards being posted each pending action: edit its card to the cancelled form - each performing action: mark it interrupted, so its perform's end edits no card; card set: edit it to the interrupted form; card unset (mode host): post the "host, interrupted" card + each performing action: mark it interrupted, so its perform's end edits no card and logs no line; card set: edit it to the interrupted form; card unset (mode host): post the "host, interrupted" card + abort the stop signal of the performs: a connect's wait for the connection ends wait for the outcome edits of performs that ended before the stop K = pending and performing actions; log "Cancelled K actions." ("1 action." for one) when K > 0 wait until the control chat's pending deliveries are 0, polled every 100 ms, at most 10 s: # an edit of a received card emits no status event (Subscriber.hs:4139-4146, 9711dcfc9) /sql chat SELECT hex(agent_conn_id) FROM connections WHERE group_member_id IN (SELECT group_member_id FROM group_members WHERE group_id = ) /sql agent SELECT count(*) FROM snd_message_deliveries WHERE failed = 0 AND hex(conn_id) IN () + an error of either query: log "delivery wait: " to stderr; the wait ends chat.close(); remove DIR/lock; log "Stopped."; exit 0 ``` @@ -755,7 +765,7 @@ ownerStart(group): no humans: openLink(observer); print the observer line else: log and post " joined (member ). Restart the server with --owners " --owners LIST: - each entry: a human with groupMemberId ID and displayName NAME; missing: exit 2 "--owners: member ID:NAME is not in the agent control chat. Start with --reset-owners --owners, or use the IDs of current members." + each entry: a human with groupMemberId ID and displayName NAME, folded to one line as the restart line prints it; missing: exit 2 "--owners: member ID:NAME is not in the agent control chat. Start with --reset-owners --owners, or use the IDs of current members." each entry without role owner: apiSetMembersRole(group, [ID], owner) # one member per call: Commands.hs:2990-2991 (9711dcfc9) each human not in LIST: apiRemoveMembers(group, [ID]) # one member per call: Commands.hs:3130 (9711dcfc9) deleteLink(); log "Owners: LIST."; print the register hint @@ -795,7 +805,7 @@ approve [CHAT] N | reject [CHAT] N, action N not pending in memory, or its card approve CHAT N | reject CHAT N, action N a connect or for another chat: reply "Action N is not for CHAT." approve [CHAT] N: decidedBy = sender; state performing; start perform(N) without awaiting it in the event handler; its end edits the card reject [CHAT] N: decidedBy = sender; state rejected; edit the card -approve CHAT all: CHAT not a chat of the agent: reply "No chat CHAT. ..."; otherwise set the chat's custom data simplexMcp = {approvedBy: sender name, approvedById: sender groupMemberId, approvedAt}, keeping its other keys +approve CHAT all: mode host: reply "Approvals apply in mode requests; this server runs in mode host."; CHAT not a chat of the agent: reply "No chat CHAT. ..."; otherwise set the chat's custom data simplexMcp = {approvedBy: sender name, approvedById: sender groupMemberId, approvedAt}, keeping its other keys (/_set custom @C or #G); each pending send of CHAT: approve as above; reply "Approved for ... /'revoke CHAT'" revoke CHAT: no approval in the chat's custom data: reply "Messages to CHAT have no standing approval."; otherwise remove simplexMcp from it; reply "Messages to ... need approval again." pending: reply with the pending actions @@ -867,7 +877,9 @@ action(tool, input, ctx): stopping: tool error "The server is stopping." lost: tool error "control chat lost" 20 actions pending: tool error "20 actions pending" - a send whose largest card exceeds 15000 bytes (section Card size): tool error "card over 15000 bytes" + 20 actions performing: tool error "20 actions performing" + a connect: the link texts on its card cut to fit 15000 bytes (section Card size) + a send or connect whose largest card exceeds 15000 bytes (section Card size): tool error "card over 15000 bytes" no approvers: tool error, the no-approver message N = nextAction; nextAction = N + 1; create action N; log "Action N: ..." /_set custom #group {simplexMcp: {control: true, nextAction}}, queued after earlier writes, awaited @@ -950,9 +962,9 @@ send(input, ctx): r = /_get chat count=0; a store error: tool error "unknown chat" r.chat.chatInfo not connected (as connected in list()): tool error "chat not connected" group or channel, r.chat.chatInfo.groupInfo.membership role below author: tool error "role below author" - reply_to i: /_get chat around= count=0 fails or has no item i: tool error "reply_to not in chat" + reply_to i: /_get chat around= count=0 fails or has no item i: tool error "reply_to not in chat"; item i not a message: tool error "reply_to not a message" mention, chat a contact chat: tool error "mention not a member" - mention m: /_members has no member m: tool error "mention not a member" + mention m: /_members has no member m: tool error "mention not a member"; two members with the same display name: tool error "mention of equal names" action(send, input, ctx) return responses(action, wait, idle, limit, ctx) with {action, status: "done"} @@ -966,7 +978,7 @@ perform send: `responses(action, wait, idle, limit, ctx)`: ``` -collected: action.responses, in arrival order: at most 100 items kept, later ones counted as overflow; an item updated by chatItemUpdated (a live message) is replaced, and idle does not end the wait while a collected item is live +collected: action.responses, in arrival order: at most 100 items kept, the IDs of later ones kept as overflow until they are deleted; an item updated by chatItemUpdated (a live message) is replaced, and idle does not end the wait while a collected item is live until: wait seconds | idle seconds after the last collected item | the call's signal; re-evaluated at each newChatItems of chat signal: return progress at once and every 60 s: " responses in ; waiting until