diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e33ba607e..7e5f16200a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APKs diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index b7912b2b72..71195ff163 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -61,7 +61,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug Gplay Enterprise APK diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 5006cc44b5..8780cd7c7b 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -23,7 +23,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index f320230584..391fe7daf6 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -1,149 +1,163 @@ -name: Maestro (local) +name: Maestro (local) [disabled] -# Run this flow only when APK Build workflow completes on: workflow_dispatch: - pull_request: permissions: {} -# Enrich gradle.properties for CI/CD -env: - GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g - CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache - ARCH: x86_64 - DEVICE: pixel_7_pro - API_LEVEL: 33 - TARGET: google_apis - jobs: - build-apk: - name: Build APK + disabled: + name: Disabled in fork runs-on: ubuntu-latest - concurrency: - group: ${{ format('maestro-build-{0}', github.ref) }} - cancel-in-progress: true steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be - with: - # This might remove tools that are actually needed, if set to "true" but frees about 6 GB - tool-cache: true - # All of these default to true, but we should only need the 'android' one (and maybe swap-storage?) - android: false - dotnet: true - haskell: true - # This takes way too long to run (~2 minutes) and it saves only ~5.5GB - large-packages: false - docker-images: true - swap-storage: false + - run: echo "Maestro workflow is disabled in this fork." - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Ensure we are building the branch and not the branch after being merged on develop - # https://github.com/actions/checkout/issues/881 - ref: ${{ github.ref }} - persist-credentials: false - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - name: Use JDK 21 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' - - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - - name: Assemble debug APK - run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES - env: - ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} - ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} - ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - - name: Upload APK as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: elementx-apk-maestro - path: | - app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk - retention-days: 5 - overwrite: true - if-no-files-found: error +# name: Maestro (local) - maestro-cloud: - name: Maestro test suite - runs-on: ubuntu-latest - needs: [ build-apk ] - # Allow only one to run at a time, since they use the same environment. - # Otherwise, tests running in parallel can break each other. - concurrency: - group: maestro-test - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' - with: - # Ensure we are building the branch and not the branch after being merged on develop - # https://github.com/actions/checkout/issues/881 - ref: ${{ github.ref }} - persist-credentials: false - - name: Download APK artifact from previous job - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: elementx-apk-maestro - - name: Enable KVM group perms - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: Install maestro - run: curl -fsSL "https://get.maestro.mobile.dev" | bash - - name: Run Maestro tests in emulator - id: maestro_test - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 - continue-on-error: true - env: - MAESTRO_USERNAME: maestroelement - MAESTRO_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} - MAESTRO_RECOVERY_KEY: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_RECOVERY_KEY }} - MAESTRO_ROOM_NAME: MyRoom - MAESTRO_INVITEE1_MXID: "@maestroelement2:matrix.org" - MAESTRO_INVITEE2_MXID: "@maestroelement3:matrix.org" - MAESTRO_APP_ID: io.element.android.x.debug - with: - api-level: ${{ env.API_LEVEL }} - arch: ${{ env.ARCH }} - profile: ${{ env.DEVICE }} - target: ${{ env.TARGET }} - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - disk-size: 3G - script: | - .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk - - name: Upload test results - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: test-results - path: | - ~/.maestro/tests/** - retention-days: 5 - overwrite: true - if-no-files-found: error - - name: Update summary (success) - if: steps.maestro_test.outcome == 'success' - run: | - echo "### Maestro tests worked :rocket:!" >> $GITHUB_STEP_SUMMARY - - name: Update summary (failure) - if: steps.maestro_test.outcome != 'success' - run: | - LOG_FILE=$(find ~/.maestro/tests/ -name maestro.log) - echo "Log file: $LOG_FILE" - LOG_LINES="$(tail -n 30 $LOG_FILE)" - echo "### :x: Maestro tests failed... - - \`\`\` - $LOG_LINES - \`\`\`" >> $GITHUB_STEP_SUMMARY - - name: Fail the workflow in case of error in test - if: steps.maestro_test.outcome != 'success' - run: | - echo "Maestro tests failed. Please check the logs." - exit 1 +# # Run this flow only when APK Build workflow completes +# on: +# workflow_dispatch: +# pull_request: + +# permissions: {} + +# # Enrich gradle.properties for CI/CD +# env: +# GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g +# CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache +# ARCH: x86_64 +# DEVICE: pixel_7_pro +# API_LEVEL: 33 +# TARGET: google_apis + +# jobs: +# build-apk: +# name: Build APK +# runs-on: ubuntu-latest +# concurrency: +# group: ${{ format('maestro-build-{0}', github.ref) }} +# cancel-in-progress: true +# steps: +# - name: Free Disk Space (Ubuntu) +# uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be +# with: +# # This might remove tools that are actually needed, if set to "true" but frees about 6 GB +# tool-cache: true +# # All of these default to true, but we should only need the 'android' one (and maybe swap-storage?) +# android: false +# dotnet: true +# haskell: true +# # This takes way too long to run (~2 minutes) and it saves only ~5.5GB +# large-packages: false +# docker-images: true +# swap-storage: false +# +# - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# with: +# # Ensure we are building the branch and not the branch after being merged on develop +# # https://github.com/actions/checkout/issues/881 +# ref: ${{ github.ref }} +# persist-credentials: false +# - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 +# name: Use JDK 21 +# with: +# distribution: "temurin" # See 'Supported distributions' for available options +# java-version: "21" +# - name: Configure gradle +# uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 +# with: +# cache-read-only: ${{ github.ref != 'refs/heads/develop' }} +# - name: Assemble debug APK +# run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES +# env: +# ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} +# ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} +# ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} +# - name: Upload APK as artifact +# uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 +# with: +# name: elementx-apk-maestro +# path: | +# app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk +# retention-days: 5 +# overwrite: true +# if-no-files-found: error + +# maestro-cloud: +# name: Maestro test suite +# runs-on: ubuntu-latest +# needs: [build-apk] +# # Allow only one to run at a time, since they use the same environment. +# # Otherwise, tests running in parallel can break each other. +# concurrency: +# group: maestro-test +# steps: +# - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' +# with: +# # Ensure we are building the branch and not the branch after being merged on develop +# # https://github.com/actions/checkout/issues/881 +# ref: ${{ github.ref }} +# persist-credentials: false +# - name: Download APK artifact from previous job +# uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# with: +# name: elementx-apk-maestro +# - name: Enable KVM group perms +# run: | +# echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules +# sudo udevadm control --reload-rules +# sudo udevadm trigger --name-match=kvm +# - name: Install maestro +# run: curl -fsSL "https://get.maestro.mobile.dev" | bash +# - name: Run Maestro tests in emulator +# id: maestro_test +# uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 +# continue-on-error: true +# env: +# MAESTRO_USERNAME: maestroelement +# MAESTRO_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} +# MAESTRO_RECOVERY_KEY: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_RECOVERY_KEY }} +# MAESTRO_ROOM_NAME: MyRoom +# MAESTRO_INVITEE1_MXID: "@maestroelement2:matrix.org" +# MAESTRO_INVITEE2_MXID: "@maestroelement3:matrix.org" +# MAESTRO_APP_ID: io.element.android.x.debug +# with: +# api-level: ${{ env.API_LEVEL }} +# arch: ${{ env.ARCH }} +# profile: ${{ env.DEVICE }} +# target: ${{ env.TARGET }} +# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none +# disable-animations: true +# disk-size: 3G +# script: | +# .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk +# - name: Upload test results +# uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 +# with: +# name: test-results +# path: | +# ~/.maestro/tests/** +# retention-days: 5 +# overwrite: true +# if-no-files-found: error +# - name: Update summary (success) +# if: steps.maestro_test.outcome == 'success' +# run: | +# echo "### Maestro tests worked :rocket:!" >> $GITHUB_STEP_SUMMARY +# - name: Update summary (failure) +# if: steps.maestro_test.outcome != 'success' +# run: | +# LOG_FILE=$(find ~/.maestro/tests/ -name maestro.log) +# echo "Log file: $LOG_FILE" +# LOG_LINES="$(tail -n 30 $LOG_FILE)" +# echo "### :x: Maestro tests failed... +# +# \`\`\` +# $LOG_LINES +# \`\`\`" >> $GITHUB_STEP_SUMMARY +# - name: Fail the workflow in case of error in test +# if: steps.maestro_test.outcome != 'success' +# run: | +# echo "Maestro tests failed. Please check the logs." +# exit 1 diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index ae5c72a3d9..10d41f5d1a 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -43,7 +43,7 @@ jobs: java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: false @@ -85,7 +85,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d90cf07e50..da2391c416 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,7 +1,7 @@ name: Pull Request on: pull_request_target: - types: [ opened, edited, labeled, unlabeled, synchronize ] + types: [opened, edited, labeled, unlabeled, synchronize] workflow_call: # zizmor: ignore[dangerous-triggers] secrets: ELEMENT_BOT_TOKEN: @@ -23,60 +23,60 @@ jobs: script: | core.setFailed("PR has been labeled with X-Blocked; it cannot be merged."); - community-prs: - name: Label Community PRs - runs-on: ubuntu-latest - if: github.event.action == 'opened' - permissions: - pull-requests: write - steps: - - name: Check membership - if: github.event.pull_request.user.login != 'renovate[bot]' - uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3 - id: teams - with: - username: ${{ github.event.pull_request.user.login }} - organization: element-hq - team: Vector Core - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }} - - name: Add label - if: steps.teams.outputs.isTeamMember == 'false' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ['Z-Community-PR'] - }); + # community-prs: + # name: Label Community PRs + # runs-on: ubuntu-latest + # if: github.event.action == 'opened' + # permissions: + # pull-requests: write + # steps: + # - name: Check membership + # if: github.event.pull_request.user.login != 'renovate[bot]' + # uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3 + # id: teams + # with: + # username: ${{ github.event.pull_request.user.login }} + # organization: element-hq + # team: Vector Core + # GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }} + # - name: Add label + # if: steps.teams.outputs.isTeamMember == 'false' + # uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + # with: + # script: | + # github.rest.issues.addLabels({ + # issue_number: context.issue.number, + # owner: context.repo.owner, + # repo: context.repo.repo, + # labels: ['Z-Community-PR'] + # }); - close-if-fork-develop: - name: Forbid develop branch fork contributions - runs-on: ubuntu-latest - permissions: - # Require to comment and close the PR. - pull-requests: write - if: > - github.event.action == 'opened' && - github.event.pull_request.head.ref == 'develop' && - github.event.pull_request.head.repo.full_name != github.repository - steps: - - name: Close pull request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" + - " branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity.", - }); - - github.rest.pulls.update({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - state: 'closed' - }); + # close-if-fork-develop: + # name: Forbid develop branch fork contributions + # runs-on: ubuntu-latest + # permissions: + # # Require to comment and close the PR. + # pull-requests: write + # if: > + # github.event.action == 'opened' && + # github.event.pull_request.head.ref == 'develop' && + # github.event.pull_request.head.repo.full_name != github.repository + # steps: + # - name: Close pull request + # uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + # with: + # script: | + # github.rest.issues.createComment({ + # issue_number: context.issue.number, + # owner: context.repo.owner, + # repo: context.repo.repo, + # body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" + + # " branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity.", + # }); + # + # github.rest.pulls.update({ + # pull_number: context.issue.number, + # owner: context.repo.owner, + # repo: context.repo.repo, + # state: 'closed' + # }); diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7845be8323..1f716a3502 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -5,7 +5,7 @@ on: pull_request: merge_group: push: - branches: [ main, develop ] + branches: [main, develop] permissions: {} @@ -71,10 +71,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 @@ -110,10 +110,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Konsist tests @@ -151,10 +151,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run compose tests @@ -185,10 +185,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build Gplay Debug @@ -230,10 +230,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt @@ -271,10 +271,10 @@ jobs: - name: Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check @@ -327,16 +327,16 @@ jobs: with: severity: warning - zizmor: - name: Run zizmor - runs-on: ubuntu-latest - permissions: - security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + # zizmor: + # name: Run zizmor + # runs-on: ubuntu-latest + # permissions: + # security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + # steps: + # - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # with: + # persist-credentials: false + # - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 upload_reports: name: Project Check Suite diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index 189ae26cf3..4bec9643c5 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -59,7 +59,7 @@ jobs: java-version: '21' # Add gradle cache, this should speed up the process - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Record screenshots diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f9df37160..b5514a72f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 - name: Create app bundle env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -87,7 +87,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 - name: Create Enterprise app bundle env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -131,7 +131,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 - name: Create APKs env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index d945b18f5c..1abcc76530 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -5,7 +5,7 @@ on: pull_request: merge_group: push: - branches: [ main, develop ] + branches: [main, develop] permissions: {} @@ -16,48 +16,48 @@ env: GROUP: ${{ format('sonar-{0}', github.ref) }} jobs: - sonar: - name: Sonar Quality Checks - runs-on: ubuntu-latest - # Allow all jobs on main and develop. Just one per PR. - concurrency: - group: ${{ format('sonar-{0}', github.ref) }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be - with: - # This might remove tools that are actually needed, if set to "true" but frees about 6 GB - tool-cache: true - # All of these default to true, but we should only need the 'android' one (and maybe swap-storage?) - android: false - dotnet: true - haskell: true - # This takes way too long to run (~2 minutes) and it saves only ~5.5GB - large-packages: false - docker-images: true - swap-storage: false - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Ensure we are building the branch and not the branch after being merged on develop - # https://github.com/actions/checkout/issues/881 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - persist-credentials: false - - name: Use JDK 21 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' - - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - - name: Build debug code and test fixtures - run: ./gradlew assembleGplayDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES - - name: 🔊 Publish results to Sonar - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} - if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} - run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES + # sonar: + # name: Sonar Quality Checks + # runs-on: ubuntu-latest + # # Allow all jobs on main and develop. Just one per PR. + # concurrency: + # group: ${{ format('sonar-{0}', github.ref) }} + # cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + # steps: + # - name: Free Disk Space (Ubuntu) + # uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + # with: + # # This might remove tools that are actually needed, if set to "true" but frees about 6 GB + # tool-cache: true + # # All of these default to true, but we should only need the 'android' one (and maybe swap-storage?) + # android: false + # dotnet: true + # haskell: true + # # This takes way too long to run (~2 minutes) and it saves only ~5.5GB + # large-packages: false + # docker-images: true + # swap-storage: false + # + # - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # with: + # # Ensure we are building the branch and not the branch after being merged on develop + # # https://github.com/actions/checkout/issues/881 + # ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + # persist-credentials: false + # - name: Use JDK 21 + # uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + # with: + # distribution: "temurin" # See 'Supported distributions' for available options + # java-version: "21" + # - name: Configure gradle + # uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 + # with: + # cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + # - name: Build debug code and test fixtures + # run: ./gradlew assembleGplayDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES + # - name: 🔊 Publish results to Sonar + # env: + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} + # if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} + # run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 914bf4b35c..e37076b9de 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -22,7 +22,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 602ad1e18e..b926e7a97b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,7 +5,7 @@ on: pull_request: merge_group: push: - branches: [ main, develop ] + branches: [main, develop] permissions: {} @@ -65,10 +65,10 @@ jobs: - name: ☕️ Use JDK 21 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "21" - name: Configure gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} @@ -101,16 +101,16 @@ jobs: if: failure() run: | echo """## Tests failed! - + """ >> $GITHUB_STEP_SUMMARY python3 .github/workflows/scripts/parse_test_failures.py . >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY # https://github.com/codecov/codecov-action - - name: ☂️ Upload coverage reports to codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 - with: - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - files: build/reports/kover/reportMerged.xml - verbose: true + # - name: ☂️ Upload coverage reports to codecov + # uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + # with: + # fail_ci_if_error: true + # token: ${{ secrets.CODECOV_TOKEN }} + # files: build/reports/kover/reportMerged.xml + # verbose: true diff --git a/CHANGES.md b/CHANGES.md index 1b002992fb..37c4e23d0a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,49 @@ +Changes in Element X v26.04.0 +============================= + +## What's Changed +### ✨ Features +* Add floating/sticky date badge in the timeline by @kalix127 in https://github.com/element-hq/element-x-android/pull/6496 +### 🐛 Bugfixes +* Fix `ForegroundServiceDidNotStartInTimeException` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6470 +* Fix media cover placeholder floating by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6484 +* Try handling `ForegroundServiceStartNotAllowedException` better by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6483 +* Fix crash when using `View.hideKeyboardAndAwaitAnimation` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6502 +* Fix content scrolling not working in the RTE by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6492 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6486 +### 🧱 Build +* Add instructions for AI by @bmarty in https://github.com/element-hq/element-x-android/pull/6468 +* Fix permissions to publish GitHub pages. by @bmarty in https://github.com/element-hq/element-x-android/pull/6500 +* Try fixing location pin previews by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6495 +* CI: yet another Maestro fix by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6505 +### 📄 Documentation +* Add some instructions for features to the community PR notice message by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6465 +### 🚧 In development 🚧 +* Setup live location sharing feature by @ganfra in https://github.com/element-hq/element-x-android/pull/6342 +### Dependency upgrades +* Update dependency io.sentry:sentry-android to v8.36.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6461 +* Update metro to v0.11.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6448 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v8.0.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/6459 +* Update sqldelight to v2.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6449 +* Update nschloe/action-cached-lfs-checkout action to v1.2.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6442 +* Update kotlin to v2.3.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6437 +* Update dependency io.element.android:element-call-embedded to v0.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6358 +* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6474 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6478 +* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6487 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6499 +* fix(deps): update dependency com.posthog:posthog-android to v3.39.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6504 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.03.31 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6494 +### Others +* Iterate on space header by @bmarty in https://github.com/element-hq/element-x-android/pull/6456 +* Add margin after bullet points by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6446 +* chore: update the build-rust-sdk script by @bnjbvr in https://github.com/element-hq/element-x-android/pull/6476 +* Update replied message UI by @bmarty in https://github.com/element-hq/element-x-android/pull/6472 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.03.4...v26.04.0 + Changes in Element X v26.03.4 ============================= diff --git a/FORK_DIVERGENCE.md b/FORK_DIVERGENCE.md new file mode 100644 index 0000000000..f06b0f225a --- /dev/null +++ b/FORK_DIVERGENCE.md @@ -0,0 +1,65 @@ +# FORK_DIVERGENCE + +This document tracks the functional and behavioral differences between this forked repository and its upstream baseline. Each entry represents a deliberate divergence introduced to meet the specific needs of this fork. + +--- + +### **D-0001: Rebranded application icons and logos** + +- **Upstream baseline commit**: `4ad495d36c6ccde088848a2ac90c7db62e963421` +- **Type**: `Behavioral` +- **Date**: `2026-04-15` + +#### **Description** + +This change replaces the default Element X application icons and logos with a custom set to reflect the fork's branding. + +#### **Upstream vs. Fork Behavior** + +- **Upstream**: The application displays the official Element branding. +- **Fork**: The application displays custom logos and icons. + +#### **Files Modified** + +- `.github/workflows/maestro-local.yml` +- `.github/workflows/pull_request.yml` +- `.github/workflows/quality.yml` +- `.github/workflows/sonar.yml` +- `.github/workflows/tests.yml` +- `appicon/element/src/main/ic_launcher-playstore.png` +- `appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp` +- `appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp` +- `appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp` +- `appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp` +- `appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp` +- `appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp` +- `appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp` +- `appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp` +- `appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp` +- `appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp` +- `appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp` +- `appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp` +- `appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp` +- `appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp` +- `appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp` +- `appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp` +- `appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp` +- `appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp` +- `appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp` +- `appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp` +- `libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png` +- `libraries/designsystem/src/main/res/drawable-night/bg_migration.png` +- `libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png` +- `libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png` +- `libraries/designsystem/src/main/res/drawable/bg_migration.png` +- `libraries/designsystem/src/main/res/drawable/onboarding_bg.png` +- `libraries/designsystem/src/main/res/drawable/sample_background.webp` + +#### **Impact & Risk** + +- **Impact**: This change alters the visual identity of the application. It does not affect core functionality. The modifications to GitHub workflow files may tailor the CI/CD process for the fork. +- **Risk Level**: `LOW`. The changes are primarily cosmetic. + +#### **AGPL Relevance** + +- Not applicable. diff --git a/UPSTREAM_VERSION b/UPSTREAM_VERSION new file mode 100644 index 0000000000..a4bc77b0ae --- /dev/null +++ b/UPSTREAM_VERSION @@ -0,0 +1,7 @@ +# Upstream Version + +repo: element-hq/element-x-android +upstream_url: +sync_strategy: manual +commit: 4ad495d36c6ccde088848a2ac90c7db62e963421 +date: 2026-04-01 diff --git a/appicon/element/src/main/ic_launcher-playstore.png b/appicon/element/src/main/ic_launcher-playstore.png index 325bf570f5..2c253336f7 100644 Binary files a/appicon/element/src/main/ic_launcher-playstore.png and b/appicon/element/src/main/ic_launcher-playstore.png differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp index 2ae0da8d0f..90d83be176 100644 Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index e40370b86f..b1b741a3ff 100644 Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index bcc8059674..59edbb380d 100644 Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 8ad6b74901..8e7f9efc3a 100644 Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp index d4e1b90f22..a64c21cc71 100644 Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index ac2361f8b0..f6122a33bf 100644 Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp index 62e0fb81a7..c81f2ff79d 100644 Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 3cd52b2182..a03eb5eedf 100644 Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp index 527b23880a..2cc7c7da31 100644 Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index f8c5c5f218..7b2ee4cfba 100644 Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index d7c1ffcc72..00d263b229 100644 Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1c98f35c9f..93481b21b1 100644 Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp index ed524b893c..cc66c925bf 100644 Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index bb401bcb37..3953ccdb14 100644 Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index 4f5c924f1f..b5cd473417 100644 Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index a6b0547ed0..2b011f9eb5 100644 Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 359e3921a1..891a788b64 100644 Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index f0f9a63324..e4833d502d 100644 Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index c3490dc0b4..e10f03ce55 100644 Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 36125792fe..370cdb7581 100644 Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a676df1d32..44c1060e10 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -422,6 +422,10 @@ class LoggedInFlowNode( override fun navigateToGlobalNotificationSettings() { backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) } + + override fun navigateToDeveloperSettings() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.DeveloperSettings)) + } } val inputs = RoomFlowNode.Inputs( roomIdOrAlias = navTarget.roomIdOrAlias, @@ -744,11 +748,11 @@ private class AttachRoomOperation( } } + // Always create a new element, otherwise we wouldn't be navigating to the target event id or child node BackStackElement( - key = NavKey(roomTarget), - fromState = CREATED, - targetState = ACTIVE, - operation = this - ) + key = NavKey(roomTarget), + fromState = CREATED, + targetState = ACTIVE, + operation = this + ) } else { // Otherwise, just push the new node to the end of the backstack Push(roomTarget).invoke(currentElements) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index d0d3df590d..febd15e9c2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -85,6 +85,7 @@ class JoinedRoomLoadedFlowNode( fun navigateToRoom(roomId: RoomId, serverNames: List) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun navigateToGlobalNotificationSettings() + fun navigateToDeveloperSettings() } data class Inputs( @@ -145,6 +146,10 @@ class JoinedRoomLoadedFlowNode( callback.navigateToGlobalNotificationSettings() } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + override fun navigateToRoom(roomId: RoomId, serverNames: List) { callback.navigateToRoom(roomId, serverNames) } @@ -252,6 +257,10 @@ class JoinedRoomLoadedFlowNode( override fun navigateToRoom(roomId: RoomId) { callback.navigateToRoom(roomId, emptyList()) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } val params = MessagesEntryPoint.Params( MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt index 40778ae353..2f17071870 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt @@ -16,4 +16,5 @@ class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback { override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 92c27d9f21..46bc4a55df 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.location.impl.share import app.cash.molecule.RecompositionMode @@ -37,6 +39,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index a23e337d2a..3eecd54f3e 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -38,6 +38,7 @@ interface MessagesEntryPoint : FeatureEntryPoint { fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) fun navigateToRoom(roomId: RoomId) + fun navigateToDeveloperSettings() } data class Params(val initialTarget: InitialTarget) : NodeInputs diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 01482d0df5..6ff7f7e322 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voicerecorder.api) @@ -104,4 +105,5 @@ dependencies { testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.recentemojis.test) + testImplementation(projects.libraries.slashcommands.test) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 38d0504258..d3dd21de67 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -293,6 +293,10 @@ class MessagesFlowNode( override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) createNode(buildContext, listOf(callback, inputs)) @@ -502,6 +506,10 @@ class MessagesFlowNode( override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } createNode(buildContext, listOf(inputs, callback)) } @@ -567,7 +575,7 @@ class MessagesFlowNode( assetType = event.content.assetType, ) NavTarget.LocationViewer( - mode = mode + mode = mode ).takeIf { locationService.isServiceAvailable() } } else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 2ec5c0bcbf..e475f579c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -23,6 +23,8 @@ interface MessagesNavigator { fun navigateToEditPoll(eventId: EventId) fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) + fun navigateToMember(userId: UserId) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun navigateToDeveloperSettings() fun close() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 0c0b3e5448..20cdc51035 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -105,7 +105,7 @@ class MessagesNode( private val timelineController = TimelineController(room, room.liveTimeline) private val presenter = presenterFactory.create( navigator = this, - composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), actionListPresenter = actionListPresenterFactory.create( postProcessor = TimelineItemActionPostProcessor.Default, @@ -130,6 +130,7 @@ class MessagesNode( fun navigateToRoomDetails() fun navigateToPinnedMessagesList() fun navigateToKnockRequestsList() + fun navigateToDeveloperSettings() } override fun onBuilt() { @@ -222,10 +223,18 @@ class MessagesNode( } } + override fun navigateToMember(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { callback.navigateToThread(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + private fun displaySameRoomToast() { context.toast(CommonStrings.screen_room_permalink_same_room_android) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt index ae82c60f2a..982ca7dfd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt @@ -36,4 +36,5 @@ sealed interface MessageComposerEvent { data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent data object SaveDraft : MessageComposerEvent + data object ClearSlashError : MessageComposerEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ed22a5e2ee..90b91691a9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -33,12 +34,14 @@ import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.location.api.LocationService import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.Attachment.Media import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes @@ -68,6 +71,9 @@ import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.api.SlashCommandService +import io.element.android.libraries.slashcommands.api.message import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState @@ -104,6 +110,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes class MessageComposerPresenter( @Assisted private val navigator: MessagesNavigator, @Assisted private val timelineController: TimelineController, + @Assisted private val isInThread: Boolean, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom, private val mediaPickerProvider: PickerProvider, @@ -125,10 +132,15 @@ class MessageComposerPresenter( private val suggestionsProcessor: SuggestionsProcessor, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val notificationConversationService: NotificationConversationService, + private val slashCommandService: SlashCommandService, ) : Presenter { @AssistedFactory interface Factory { - fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter + fun create( + timelineController: TimelineController, + navigator: MessagesNavigator, + isInThread: Boolean, + ): MessageComposerPresenter } private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode()) @@ -218,6 +230,8 @@ class MessageComposerPresenter( } ) + val slashCommandAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + LaunchedEffect(Unit) { val draft = draftService.loadDraft( roomId = room.roomId, @@ -246,12 +260,13 @@ class MessageComposerPresenter( sessionCoroutineScope.sendMessage( markdownTextEditorState = markdownTextEditorState, richTextEditorState = richTextEditorState, + slashCommandAction = slashCommandAction, ) } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( + attachment = Media( localMedia = localMediaFactory.createFromUri( uri = event.uri, mimeType = null, @@ -340,6 +355,9 @@ class MessageComposerPresenter( val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch richTextEditorState.insertMentionAtSuggestion(text = text, link = link) } + is ResolvedSuggestion.Command -> { + richTextEditorState.replaceSuggestion(suggestion.command.command) + } } } else if (markdownTextEditorState.currentSuggestion != null) { markdownTextEditorState.insertSuggestion( @@ -354,6 +372,9 @@ class MessageComposerPresenter( val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) sessionCoroutineScope.updateDraft(draft, isVolatile = false) } + MessageComposerEvent.ClearSlashError -> { + slashCommandAction.value = AsyncAction.Uninitialized + } } } @@ -385,6 +406,7 @@ class MessageComposerPresenter( suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, + slashCommandAction = slashCommandAction.value, eventSink = ::handleEvent, ) } @@ -422,6 +444,7 @@ class MessageComposerPresenter( roomAliasSuggestions = roomAliasSuggestions, currentUserId = currentUserId, canSendRoomMention = ::canSendRoomMention, + isInThread = isInThread, ) suggestions.clear() suggestions.addAll(result) @@ -433,9 +456,69 @@ class MessageComposerPresenter( private fun CoroutineScope.sendMessage( markdownTextEditorState: MarkdownTextEditorState, richTextEditorState: RichTextEditorState, + slashCommandAction: MutableState>, ) = launch { val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true) val capturedMode = messageComposerContext.composerMode + + val slashCommand = if (capturedMode is MessageComposerMode.Normal) { + slashCommandService.parse( + textMessage = message.markdown, + formattedMessage = message.html, + isInThreadTimeline = isInThread, + ) + } else { + SlashCommand.NotACommand + } + + when (slashCommand) { + is SlashCommand.NotACommand -> Unit + is SlashCommand.Error -> { + slashCommandAction.value = AsyncAction.Failure(Exception(slashCommand.message())) + return@launch + } + is SlashCommand.SlashCommandNavigation -> { + when (slashCommand) { + is SlashCommand.ShowUser -> { + navigator.navigateToMember(slashCommand.userId) + } + SlashCommand.DevTools -> { + navigator.navigateToDeveloperSettings() + } + } + resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) + return@launch + } + is SlashCommand.SlashCommandSendMessage -> { + timelineController.invokeOnCurrentTimeline { + slashCommandService.proceedSendMessage(slashCommand, this) + .onFailure { cause -> + Timber.e(cause, "Failed to proceed with admin slash command") + slashCommandAction.value = AsyncAction.Failure(cause) + } + .onSuccess { + // Reset composer + resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) + } + } + return@launch + } + is SlashCommand.SlashCommandAdmin -> { + slashCommandAction.value = AsyncAction.Loading + slashCommandService.proceedAdmin(slashCommand) + .onFailure { cause -> + Timber.e(cause, "Failed to proceed with admin slash command") + slashCommandAction.value = AsyncAction.Failure(cause) + } + .onSuccess { + // Reset composer + resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) + slashCommandAction.value = AsyncAction.Uninitialized + } + return@launch + } + } + // Reset composer right away resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 424e8c07b9..f3fdb3d59a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Stable +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState @@ -26,5 +27,6 @@ data class MessageComposerState( val suggestions: ImmutableList, val resolveMentionDisplay: (String, String) -> TextDisplay, val resolveAtRoomMentionDisplay: () -> TextDisplay, + val slashCommandAction: AsyncAction, val eventSink: (MessageComposerEvent) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index a06bf30dad..ef9cd7933b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState @@ -32,6 +33,7 @@ fun aMessageComposerState( showAttachmentSourcePicker: Boolean = false, canShareLocation: Boolean = true, suggestions: ImmutableList = persistentListOf(), + slashCommandAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (MessageComposerEvent) -> Unit = {}, ) = MessageComposerState( textEditorState = textEditorState, @@ -43,5 +45,6 @@ fun aMessageComposerState( suggestions = suggestions, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + slashCommandAction = slashCommandAction, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 4b346e0c15..d387bc8765 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer. import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState +import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.TextComposer @@ -115,6 +116,12 @@ internal fun MessageComposerView( onTyping = ::onTyping, onSelectRichContent = ::sendUri, ) + + AsyncActionView( + async = state.slashCommandAction, + onSuccess = {}, + onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) }, + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt index e9e38e1730..678ef2ba56 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -63,6 +65,7 @@ fun SuggestionsPickerView( is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Alias -> suggestion.roomId.value + is ResolvedSuggestion.Command -> suggestion.command.command } } ) { @@ -91,54 +94,81 @@ private fun SuggestionItemView( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.clickable { onSelectSuggestion(suggestion) }, - horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clickable { onSelectSuggestion(suggestion) } + .padding(horizontal = 16.dp), ) { val avatarSize = AvatarSize.Suggestion val avatarData = when (suggestion) { is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) + is ResolvedSuggestion.Command -> null } val avatarType = when (suggestion) { - is ResolvedSuggestion.Alias -> AvatarType.Room() + is ResolvedSuggestion.Alias -> Room() ResolvedSuggestion.AtRoom, is ResolvedSuggestion.Member -> AvatarType.User + is ResolvedSuggestion.Command -> null } val title = when (suggestion) { is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.Member -> suggestion.roomMember.displayName is ResolvedSuggestion.Alias -> suggestion.roomName + is ResolvedSuggestion.Command -> suggestion.command.command + } + val details = when (suggestion) { + is ResolvedSuggestion.AtRoom, + is ResolvedSuggestion.Member, + is ResolvedSuggestion.Alias -> null + is ResolvedSuggestion.Command -> suggestion.command.parameters } val subtitle = when (suggestion) { is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Alias -> suggestion.roomAlias.value + is ResolvedSuggestion.Command -> suggestion.command.description + } + if (avatarData != null && avatarType != null) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp), + ) } - Avatar( - avatarData = avatarData, - avatarType = avatarType, - modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp), - ) Column( modifier = Modifier .fillMaxWidth() - .padding(end = 16.dp, top = 8.dp, bottom = 8.dp) + .padding(top = 8.dp, bottom = 8.dp) .align(Alignment.CenterVertically), ) { - title?.let { - Text( - text = it, - style = ElementTheme.typography.fontBodyLgRegular, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + title?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + details?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + ) + } } Text( text = subtitle, style = ElementTheme.typography.fontBodySmRegular, color = ElementTheme.colors.textSecondary, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } @@ -174,7 +204,21 @@ internal fun SuggestionsPickerViewPreview() { roomId = RoomId("!room:matrix.org"), roomName = "My room", roomAvatarUrl = null, - ) + ), + ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/noparam", + parameters = null, + description = "A slash command without parameters", + ) + ), + ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/withparam", + parameters = " [reason]", + description = "A slash command with parameters", + ) + ), ), onSelectSuggestion = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt index 789a027cf7..010aff5d4b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.slashcommands.api.SlashCommandService import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -23,7 +24,9 @@ import io.element.android.libraries.textcomposer.model.SuggestionType * This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer. */ @Inject -class SuggestionsProcessor { +class SuggestionsProcessor( + private val slashCommandService: SlashCommandService, +) { /** * Process the suggestion. * @param suggestion The current suggestion input @@ -31,6 +34,7 @@ class SuggestionsProcessor { * @param roomAliasSuggestions The available room alias suggestions * @param currentUserId The current user id * @param canSendRoomMention Should return true if the current user can send room mentions + * @param isInThread Whether the composer is in a thread or not, used to filter slash commands suggestions * @return The list of suggestions to display */ suspend fun process( @@ -39,6 +43,7 @@ class SuggestionsProcessor { roomAliasSuggestions: List, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, + isInThread: Boolean, ): List { suggestion ?: return emptyList() return when (suggestion.type) { @@ -69,7 +74,16 @@ class SuggestionsProcessor { ) } } - SuggestionType.Command, + SuggestionType.Command -> { + // Command suggestions are valid only if this is the beginning of the message + if (suggestion.start == 0) { + slashCommandService.getSuggestions(suggestion.text, isInThread).map { + ResolvedSuggestion.Command(it) + } + } else { + emptyList() + } + } SuggestionType.Emoji, is SuggestionType.Custom -> { // Clear suggestions diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 23bcbe99bd..4bb3471660 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -112,7 +112,7 @@ class ThreadedMessagesNode( this.timelineController = timelineController return presenterFactory.create( navigator = this, - composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), // TODO add special processor for threaded timeline actionListPresenter = actionListPresenterFactory.create( @@ -136,6 +136,7 @@ class ThreadedMessagesNode( fun navigateToEditPoll(eventId: EventId) fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun navigateToDeveloperSettings() } override fun onBuilt() { @@ -233,10 +234,18 @@ class ThreadedMessagesNode( callback.handlePermalinkClick(permalinkData) } + override fun navigateToMember(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { callback.navigateToThread(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + override fun close() = navigateUp() @Composable diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt index a1db09dfda..dc50fca2c3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest { override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun navigateToRoom(roomId: RoomId) = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() } val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) val params = MessagesEntryPoint.Params(initialTarget) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 68d2cd824b..44d82f1a7c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -24,6 +24,8 @@ class FakeMessagesNavigator( private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List) -> Unit = { _, _, _ -> lambdaError() }, + private val navigateToMemberLambda: (userId: UserId) -> Unit = { lambdaError() }, + private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() }, private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val closeLambda: () -> Unit = { lambdaError() }, ) : MessagesNavigator { @@ -51,10 +53,18 @@ class FakeMessagesNavigator( onNavigateToRoomLambda(roomId, eventId, serverNames) } + override fun navigateToMember(userId: UserId) { + navigateToMemberLambda(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { onOpenThreadLambda(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + navigateToDeveloperSettingsLambda() + } + override fun close() { closeLambda() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt new file mode 100644 index 0000000000..116a1cfb5d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl.messagecomposer + +import android.net.Uri +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.LocationService +import io.element.android.features.location.test.FakeLocationService +import io.element.android.features.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.draft.ComposerDraftService +import io.element.android.features.messages.impl.draft.FakeComposerDraftService +import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter +import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper +import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.api.SlashCommandService +import io.element.android.libraries.slashcommands.test.FakeSlashCommandService +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MessageComposerPresenterSlashCommandTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val pickerProvider = FakePickerProvider().apply { + givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk + } + private val mediaPreProcessor = FakeMediaPreProcessor() + private val snackbarDispatcher = SnackbarDispatcher() + private val mockMediaUrl: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) + private val analyticsService = FakeAnalyticsService() + private val notificationConversationService = FakeNotificationConversationService() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.isFullScreen).isFalse() + assertThat(initialState.textEditorState.messageHtml()).isEmpty() + assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) + assertThat(initialState.showAttachmentSourcePicker).isFalse() + assertThat(initialState.canShareLocation).isTrue() + } + } + + @Test + fun `present - slash command error sets failure`() = runTest { + val presenter = createPresenter( + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.ErrorUnknownSlashCommand(A_FAILURE_REASON) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + val errorState = awaitItem() + assertThat(errorState.slashCommandAction.isFailure()).isTrue() + assertThat(errorState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON) + // Composer should not be reset when command is an error + assertThat(errorState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + // Close the error + errorState.eventSink(MessageComposerEvent.ClearSlashError) + val finalState = awaitItem() + assertThat(finalState.slashCommandAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - slash command navigation ShowUser navigates to member and resets composer`() = runTest { + val navigateToMember = lambdaRecorder {} + val navigator = FakeMessagesNavigator(navigateToMemberLambda = navigateToMember) + val presenter = createPresenter( + navigator = navigator, + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.ShowUser(A_USER_ID) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + advanceUntilIdle() + // navigation should be invoked and composer reset + navigateToMember.assertions().isCalledOnce().with(value(A_USER_ID)) + assertThat(initialState.textEditorState.messageHtml()).isEmpty() + } + } + + @Test + fun `present - slash command navigation DevTools navigates to developer settings and resets composer`() = runTest { + val navigateToDev = lambdaRecorder { } + val navigator = FakeMessagesNavigator(navigateToDeveloperSettingsLambda = navigateToDev) + val presenter = createPresenter( + navigator = navigator, + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.DevTools } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + advanceUntilIdle() + navigateToDev.assertions().isCalledOnce() + assertThat(initialState.textEditorState.messageHtml()).isEmpty() + } + } + + @Test + fun `present - slash command send message proceeds and resets composer`() = runTest { + val presenter = createPresenter( + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.SendPlainText(A_MESSAGE) }, + proceedSendMessageResult = { _, _ -> Result.success(Unit) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + advanceUntilIdle() + // Composer reset after successful slash send + assertThat(initialState.textEditorState.messageHtml()).isEmpty() + // Ensure no failure + assertThat(initialState.slashCommandAction.isFailure()).isFalse() + } + } + + @Test + fun `present - slash command send message failure sets failure state`() = runTest { + val presenter = createPresenter( + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.SendPlainText("A_MESSAGE") }, + proceedSendMessageResult = { _, _ -> Result.failure(Exception(A_FAILURE_REASON)) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + val failureState = awaitItem() + assertThat(failureState.slashCommandAction.isFailure()).isTrue() + assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON) + // Clear the error + failureState.eventSink(MessageComposerEvent.ClearSlashError) + val finalState = awaitItem() + assertThat(finalState.slashCommandAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - slash command admin proceeds and resets state on success`() = runTest { + val presenter = createPresenter( + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) }, + proceedAdminResult = { _ -> Result.success(Unit) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + val loadingState = awaitItem() + assertThat(loadingState.slashCommandAction.isLoading()).isTrue() + val successState = awaitItem() + // After success, state should be Uninitialized + assertThat(successState.slashCommandAction.isUninitialized()).isTrue() + assertThat(successState.textEditorState.messageHtml()).isEmpty() + } + } + + @Test + fun `present - slash command admin proceeds and emit failure on error`() = runTest { + val presenter = createPresenter( + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) }, + proceedAdminResult = { _ -> Result.failure(Exception(A_FAILURE_REASON)) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + val loadingState = awaitItem() + assertThat(loadingState.slashCommandAction.isLoading()).isTrue() + val failureState = awaitItem() + assertThat(failureState.slashCommandAction.isFailure()).isTrue() + assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON) + // Clear error + failureState.eventSink(MessageComposerEvent.ClearSlashError) + val finalState = awaitItem() + assertThat(finalState.slashCommandAction.isUninitialized()).isTrue() + } + } + + private fun TestScope.createPresenter( + room: JoinedRoom = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ), + timeline: Timeline = room.liveTimeline, + navigator: MessagesNavigator = FakeMessagesNavigator(), + pickerProvider: PickerProvider = this@MessageComposerPresenterSlashCommandTest.pickerProvider, + locationService: LocationService = FakeLocationService(true), + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterSlashCommandTest.mediaPreProcessor, + snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterSlashCommandTest.snackbarDispatcher, + permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + mentionSpanProvider: MentionSpanProvider = MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = FakeMentionSpanFormatter(), + mentionSpanTheme = MentionSpanTheme(A_USER_ID) + ), + textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), + isRichTextEditorEnabled: Boolean = true, + draftService: ComposerDraftService = FakeComposerDraftService(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + isInThread: Boolean = false, + slashCommandService: SlashCommandService = FakeSlashCommandService(), + ) = MessageComposerPresenter( + navigator = navigator, + sessionCoroutineScope = this, + isInThread = isInThread, + room = room, + mediaPickerProvider = pickerProvider, + sessionPreferencesStore = sessionPreferencesStore, + localMediaFactory = localMediaFactory, + mediaSenderFactory = MediaSenderFactory { timelineMode -> + DefaultMediaSender( + preProcessor = mediaPreProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = { + MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD + ) + } + ) + }, + snackbarDispatcher = snackbarDispatcher, + analyticsService = analyticsService, + locationService = locationService, + messageComposerContext = DefaultMessageComposerContext(), + richTextEditorStateFactory = TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), + permalinkParser = permalinkParser, + permalinkBuilder = permalinkBuilder, + timelineController = TimelineController(room, timeline), + draftService = draftService, + mentionSpanProvider = mentionSpanProvider, + pillificationHelper = textPillificationHelper, + suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + notificationConversationService = notificationConversationService, + slashCommandService = slashCommandService, + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index e16236f109..7a2cc1110a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId @@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -89,6 +91,9 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.api.SlashCommandService +import io.element.android.libraries.slashcommands.test.FakeSlashCommandService import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion @@ -144,6 +149,7 @@ class MessageComposerPresenterTest { assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() + assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -374,10 +380,13 @@ class MessageComposerPresenterTest { val presenter = createPresenter( room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, typingNoticeResult = { Result.success(Unit) } ), + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() @@ -409,10 +418,13 @@ class MessageComposerPresenterTest { isRichTextEditorEnabled = false, room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, typingNoticeResult = { Result.success(Unit) } ), + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() @@ -602,7 +614,7 @@ class MessageComposerPresenterTest { @Test fun `present - reply message`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean, _: MsgType -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -633,7 +645,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .isCalledOnce() - .with(any(), value(A_REPLY), value(A_REPLY), any(), value(false)) + .with(any(), value(A_REPLY), value(A_REPLY), any(), value(false), value(MsgType.MSG_TYPE_TEXT)) assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -967,7 +979,12 @@ class MessageComposerPresenterTest { ) givenRoomInfo(aRoomInfo(isDirect = false)) } - val presenter = createPresenter(room) + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) presenter.test { val initialState = awaitItem() @@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - send messages with intentional mentions`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean, _: MsgType -> Result.success(Unit) } val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> Result.success(Unit) } - val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List -> + val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest { liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) } ) - val presenter = createPresenter(room = room) + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), + ) presenter.test { val initialState = awaitFirstItem() @@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest { advanceUntilIdle() sendMessageResult.assertions().isCalledOnce() - .with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID)))) + .with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))), value(MsgType.MSG_TYPE_TEXT), value(false)) // Check intentional mentions on reply sent initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode())) @@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .isCalledOnce() - .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false)) + .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false), value(MsgType.MSG_TYPE_TEXT)) // Check intentional mentions on edit message skipItems(1) @@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest { isRichTextEditorEnabled: Boolean = true, draftService: ComposerDraftService = FakeComposerDraftService(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + isInThread: Boolean = false, + slashCommandService: SlashCommandService = FakeSlashCommandService(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, + isInThread = isInThread, room = room, mediaPickerProvider = pickerProvider, sessionPreferencesStore = sessionPreferencesStore, @@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest { draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, - suggestionsProcessor = SuggestionsProcessor(), + suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService), mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, + slashCommandService = slashCommandService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt index daba41fb3c..6283d7236a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -17,6 +17,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion +import io.element.android.libraries.slashcommands.test.FakeSlashCommandService import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -27,10 +29,13 @@ import org.junit.Test class SuggestionsProcessorTest { private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text) private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text) - private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "") private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "") - private val suggestionsProcessor = SuggestionsProcessor() + private val suggestionsProcessor = SuggestionsProcessor( + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) @Test fun `processing null suggestion will return empty suggestion`() = runTest { @@ -40,18 +45,59 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @Test - fun `processing Command will return empty suggestion`() = runTest { - val result = suggestionsProcessor.process( - suggestion = aCommandSuggestion, + fun `processing Command will return suggestions from the slash service`() = runTest { + val suggestionsProcessorWithCommand = SuggestionsProcessor( + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> + listOf( + SlashCommandSuggestion( + command = "aCommand", + parameters = null, + description = "A description", + ), + ) + }, + ), + ) + val result = suggestionsProcessorWithCommand.process( + suggestion = Suggestion(0, 1, SuggestionType.Command, ""), roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())), roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, + ) + assertThat(result).isNotEmpty() + } + + @Test + fun `processing Command will return empty list if start of suggestion is not 0`() = runTest { + val suggestionsProcessorWithCommand = SuggestionsProcessor( + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> + listOf( + SlashCommandSuggestion( + command = "aCommand", + parameters = null, + description = "A description", + ), + ) + }, + ), + ) + val result = suggestionsProcessorWithCommand.process( + suggestion = Suggestion(1, 2, SuggestionType.Command, ""), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -64,6 +110,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -76,6 +123,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -88,6 +136,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -100,6 +149,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -120,6 +170,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -149,6 +200,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -178,6 +230,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -198,6 +251,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -227,6 +281,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -240,6 +295,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -257,6 +313,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = UserId("@alice:server.org"), canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -270,6 +327,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -283,6 +341,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -296,6 +355,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -313,6 +373,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -331,6 +392,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { false }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 034c952f3d..bc36766bac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -12,6 +12,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID @@ -154,10 +155,10 @@ class TimelineControllerTest { @Test fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { - val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List -> + val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } - val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List -> + val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } val liveTimeline = FakeTimeline(name = "live").apply { diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 5a59d9be8a..04b471a498 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint { @Parcelize data object NotificationTroubleshoot : InitialTarget + + @Parcelize + data object DeveloperSettings : InitialTarget } data class Params(val initialElement: InitialTarget) : NodeInputs diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt index 4348b33756..57c561400c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -34,4 +34,5 @@ internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications + PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index c646923c77..15718ca0f0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -192,7 +192,11 @@ class PreferencesFlowNode( } override fun onDone() { - backstack.pop() + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } } } createNode(buildContext, listOf(developerSettingsCallback)) diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 928c08324d..07e10c65ef 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -39,6 +39,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToGlobalNotificationSettings() + fun navigateToDeveloperSettings() fun navigateToRoom(roomId: RoomId, serverNames: List) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index e1024c611f..c3ae902ba9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -388,6 +388,10 @@ class RoomDetailsFlowNode( override fun navigateToRoom(roomId: RoomId) { callback.navigateToRoom(roomId, emptyList()) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } return messagesEntryPoint.createNode( parentNode = this, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt index 5042f942b6..bcf25b2aac 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -69,6 +69,7 @@ class DefaultRoomDetailsEntryPointTest { } val callback = object : RoomDetailsEntryPoint.Callback { override fun navigateToGlobalNotificationSettings() = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index fee1278fce..fd2c8ff194 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -76,7 +76,7 @@ class SharePresenterTest { fun `present - on room selected ok`() = runTest { val joinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, ) val matrixClient = FakeMatrixClient().apply { @@ -103,7 +103,7 @@ class SharePresenterTest { fun `present - send text ok`() = runTest { val joinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, ) val matrixClient = FakeMatrixClient().apply { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ab675a239..015b50531c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,10 +19,10 @@ lifecycle = "2.10.0" activity = "1.13.0" media3 = "1.9.3" camera = "1.5.3" -work = "2.11.1" +work = "2.11.2" # Compose -compose_bom = "2026.03.00" +compose_bom = "2026.03.01" # Coroutines coroutines = "1.10.2" @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.6.1" # DI -metro = "0.11.4" +metro = "0.12.0" # Auto service autoservice = "1.1.1" @@ -64,7 +64,7 @@ detekt = "1.23.8" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.7" +kover = "0.9.8" [libraries] # Project @@ -102,7 +102,7 @@ androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.0.0" androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" -androidx_browser = "androidx.browser:browser:1.9.0" +androidx_browser = "androidx.browser:browser:1.10.0" androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_splash = "androidx.core:core-splashscreen:1.2.0" @@ -200,7 +200,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.13.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.14.0" sqlite = "androidx.sqlite:sqlite-ktx:2.6.2" unifiedpush = "org.unifiedpush.android:connector:3.3.2" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" @@ -226,7 +226,7 @@ sentry = "io.sentry:sentry-android:8.36.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.33.2" # Emojibase -matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.2" +matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.3" sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" # Di diff --git a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png index d65c54da17..ada440028b 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png and b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png index 442628a6bf..9363c8861b 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png and b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png index 2f51442fd3..57ab352ab6 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png differ diff --git a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png index a5e24f328b..ada440028b 100644 Binary files a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png and b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable/bg_migration.png b/libraries/designsystem/src/main/res/drawable/bg_migration.png index 4d889596ab..241bee4b24 100644 Binary files a/libraries/designsystem/src/main/res/drawable/bg_migration.png and b/libraries/designsystem/src/main/res/drawable/bg_migration.png differ diff --git a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png index 3b9468e357..d5dc705436 100644 Binary files a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png differ diff --git a/libraries/designsystem/src/main/res/drawable/sample_background.webp b/libraries/designsystem/src/main/res/drawable/sample_background.webp index b05f3b33a0..20d69984ba 100644 Binary files a/libraries/designsystem/src/main/res/drawable/sample_background.webp and b/libraries/designsystem/src/main/res/drawable/sample_background.webp differ diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 9fe21a10c3..a8e59e5c6d 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -169,4 +169,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + SlashCommand( + key = "feature.slash_command", + title = "Parse slash commands in the message composer", + description = "Allow parsing slash commands in the message composer and perform action.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt index 4ac480e064..462ec0535c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt @@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable { is Id -> roomId.value is Alias -> roomAlias.value } + + companion object { + fun from(id: String): RoomIdOrAlias? { + return when { + MatrixPatterns.isRoomId(id) -> Id(RoomId(id)) + MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id)) + else -> null + } + } + } } fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt index 306ab8354b..09ceaa4712 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt @@ -17,3 +17,18 @@ interface MxcTools { */ fun mxcUri2FilePath(mxcUri: String): String? } + +/** + * "mxc" scheme, including "://". So "mxc://". + */ +const val MATRIX_CONTENT_URI_SCHEME = "mxc://" + +/** + * Return true if the String starts with "mxc://". + */ +fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME) + +/** + * Remove the "mxc://" prefix. No op if the String is not a Mxc URL. + */ +fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt new file mode 100644 index 0000000000..b8d3933663 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline + +enum class MsgType { + MSG_TYPE_TEXT, + MSG_TYPE_EMOTE, + + // For future support + MSG_TYPE_SNOW, + + // For future support + MSG_TYPE_CONFETTI, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 500d9f3191..fe73230dce 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -69,6 +69,8 @@ interface Timeline : AutoCloseable { body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, + asPlainText: Boolean = false, ): Result suspend fun editMessage( @@ -90,6 +92,7 @@ interface Timeline : AutoCloseable { htmlBody: String?, intentionalMentions: List, fromNotification: Boolean = false, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, ): Result suspend fun sendImage( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 3996155871..8a184311de 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineException @@ -271,8 +272,16 @@ class RustTimeline( body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType, + asPlainText: Boolean, ): Result = withContext(dispatcher) { - MessageEventContent.from(body, htmlBody, intentionalMentions).use { content -> + MessageEventContent.from( + body = body, + htmlBody = htmlBody, + intentionalMentions = intentionalMentions, + msgType = msgType, + asPlainText = asPlainText, + ).use { content -> runCatchingExceptions { inner.send(content) } @@ -337,9 +346,15 @@ class RustTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, + msgType: MsgType, ): Result = withContext(dispatcher) { runCatchingExceptions { - val msg = MessageEventContent.from(body, htmlBody, intentionalMentions) + val msg = MessageEventContent.from( + body = body, + htmlBody = htmlBody, + intentionalMentions = intentionalMentions, + msgType = msgType, + ) inner.sendReply( msg = msg, eventId = repliedToEventId.value, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt index 3e320116c6..f1c0019f17 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt @@ -9,20 +9,54 @@ package io.element.android.libraries.matrix.impl.util import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.MessageContent +import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation +import org.matrix.rustcomponents.sdk.TextMessageContent +import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage import org.matrix.rustcomponents.sdk.messageEventContentFromHtml +import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote /** * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions. */ object MessageEventContent { - fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation { - return if (htmlBody != null) { - messageEventContentFromHtml(body, htmlBody) - } else { - messageEventContentFromMarkdown(body) - }.withMentions(intentionalMentions.map()) + fun from( + body: String, + htmlBody: String?, + intentionalMentions: List, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, + asPlainText: Boolean = false, + ): RoomMessageEventContentWithoutRelation { + return when { + asPlainText -> contentWithoutRelationFromMessage( + MessageContent( + msgType = MessageType.Text( + TextMessageContent( + body = body, + formatted = null, + ) + ), + body = body, + isEdited = false, + mentions = null, + ) + ) + htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) { + messageEventContentFromHtmlAsEmote(body, htmlBody) + } else { + messageEventContentFromHtml(body, htmlBody) + } + else -> if (msgType == MsgType.MSG_TYPE_EMOTE) { + messageEventContentFromMarkdownAsEmote(body) + } else { + messageEventContentFromMarkdown(body) + } + } + .withMentions(intentionalMentions.map()) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 4451de6276..fcc7057dbe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -64,7 +65,9 @@ class FakeTimeline( body: String, htmlBody: String?, intentionalMentions: List, - ) -> Result = { _, _, _ -> + msgType: MsgType, + asPlainText: Boolean, + ) -> Result = { _, _, _, _, _ -> lambdaError() } @@ -76,8 +79,10 @@ class FakeTimeline( body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType, + asPlainText: Boolean, ): Result = simulateLongTask { - sendMessageLambda(body, htmlBody, intentionalMentions) + sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText) } var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result = { _, _ -> @@ -134,7 +139,8 @@ class FakeTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, - ) -> Result = { _, _, _, _, _ -> + msgType: MsgType, + ) -> Result = { _, _, _, _, _, _ -> lambdaError() } @@ -144,12 +150,14 @@ class FakeTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, + msgType: MsgType, ): Result = replyMessageLambda( repliedToEventId, body, htmlBody, intentionalMentions, fromNotification, + msgType, ) var sendImageLambda: ( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt index 9713110042..27a921c219 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt @@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.push.api.push.PushHandlingWakeLock import timber.log.Timber -import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration @ContributesBinding(AppScope::class) @@ -22,24 +21,13 @@ import kotlin.time.Duration class DefaultPushHandlingWakeLock( @ApplicationContext private val context: Context, ) : PushHandlingWakeLock { - private val count = AtomicInteger(0) - override fun lock(time: Duration) { Timber.d("Acquiring wakelock for push handling, starting service.") FetchPushForegroundService.startIfNeeded(context) - - count.incrementAndGet() } override suspend fun unlock() { Timber.d("Releasing wakelock used for push handling.") FetchPushForegroundService.stop(context) - if (count.decrementAndGet() <= 0) { - Timber.d("No more wakelock needed for push handling, stopping service.") - count.set(0) - } else { - Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.") - FetchPushForegroundService.startIfNeeded(context) - } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt index 6dc6bdfa0e..da5dc80707 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt @@ -145,7 +145,13 @@ class FetchPushForegroundService : Service() { fun start(context: Context) { val intent = Intent(context, FetchPushForegroundService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) + runCatchingExceptions { context.startForegroundService(intent) } + .onFailure { throwable -> + Timber.e( + throwable, + "Failed to start FetchPushForegroundService, notifications may take longer than usual to sync" + ) + } } else { context.startService(intent) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index a52eb16b07..2cf666d92a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE @@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = - lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest { advanceUntilIdle() sendMessage.assertions() .isCalledOnce() - .with(value(A_MESSAGE), value(null), value(emptyList())) + .with( + value(A_MESSAGE), + value(null), + value(emptyList()), + value(MsgType.MSG_TYPE_TEXT), + value(false), + ) onNotifiableEventsReceivedResult.assertions() .isCalledOnce() replyMessage.assertions() @@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply blank message`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage } @@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply to thread`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = - lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest { value(A_MESSAGE), value(null), value(emptyList()), - value(true) + value(true), + value(MsgType.MSG_TYPE_TEXT), ) } diff --git a/libraries/slashcommands/api/build.gradle.kts b/libraries/slashcommands/api/build.gradle.kts new file mode 100644 index 0000000000..8cec0e65af --- /dev/null +++ b/libraries/slashcommands/api/build.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.slashcommands.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt new file mode 100644 index 0000000000..7b31ffb3b7 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.api + +enum class ChatEffect { + CONFETTI, + SNOWFALL +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt new file mode 100644 index 0000000000..713458c720 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.api + +enum class MessagePrefix { + Shrug, + TableFlip, + Unflip, + Lenny, +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt new file mode 100644 index 0000000000..770543e548 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.api + +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Represent a slash command. + */ +sealed interface SlashCommand { + // This is not a Slash command + data object NotACommand : SlashCommand + + // Slash command types: + sealed interface Error : SlashCommand + sealed interface SlashCommandSendMessage : SlashCommand + sealed interface SlashCommandAdmin : SlashCommand + sealed interface SlashCommandNavigation : SlashCommand + + // Errors + data class ErrorEmptySlashCommand(val message: String) : Error + data class ErrorCommandNotSupportedInThreads(val message: String) : Error + + // Unknown/Unsupported slash command + data class ErrorUnknownSlashCommand(val message: String) : Error + + // A slash command is detected, but there is an error + data class ErrorSyntax(val message: String) : Error + + // Valid commands: + data class SendPlainText(val message: CharSequence) : SlashCommandSendMessage + data class SendEmote(val message: CharSequence) : SlashCommandSendMessage + data class SendRainbow(val message: CharSequence) : SlashCommandSendMessage + data class SendRainbowEmote(val message: CharSequence) : SlashCommandSendMessage + data class BanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin + data class UnbanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin + data class IgnoreUser(val userId: UserId) : SlashCommandAdmin + data class UnignoreUser(val userId: UserId) : SlashCommandAdmin + data class SetUserPowerLevel(val userId: UserId, val powerLevel: Int?) : SlashCommandAdmin + data class ChangeRoomName(val name: String) : SlashCommandAdmin + data class Invite(val userId: UserId, val reason: String?) : SlashCommandAdmin + data class JoinRoom(val roomIdOrAlias: RoomIdOrAlias, val reason: String?) : SlashCommandAdmin + data class ChangeTopic(val topic: String) : SlashCommandAdmin + data class RemoveUser(val userId: UserId, val reason: String?) : SlashCommandAdmin + data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin + data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin + data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin + data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin + data class SendSpoiler(val message: String) : SlashCommandSendMessage + data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage + data object DiscardSession : SlashCommandAdmin + data class SendChatEffect(val chatEffect: ChatEffect, val message: String) : SlashCommandSendMessage + data object LeaveRoom : SlashCommandAdmin + data class UpgradeRoom(val newVersion: String) : SlashCommandAdmin + + data object DevTools : SlashCommandNavigation + data class ShowUser(val userId: UserId) : SlashCommandNavigation +} + +fun SlashCommand.Error.message() = when (this) { + is SlashCommand.ErrorEmptySlashCommand -> message + is SlashCommand.ErrorCommandNotSupportedInThreads -> message + is SlashCommand.ErrorUnknownSlashCommand -> message + is SlashCommand.ErrorSyntax -> message +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt new file mode 100644 index 0000000000..9dfca26078 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.api + +import io.element.android.libraries.matrix.api.timeline.Timeline + +interface SlashCommandService { + suspend fun getSuggestions( + text: String, + isInThread: Boolean, + ): List + + /** + * Parse the message and return a SlashCommand. + */ + suspend fun parse( + textMessage: CharSequence, + formattedMessage: String?, + isInThreadTimeline: Boolean, + ): SlashCommand + + /** + * Proceed a SlashCommandSendMessage. + */ + suspend fun proceedSendMessage( + slashCommand: SlashCommand.SlashCommandSendMessage, + timeline: Timeline, + ): Result + + /** + * Proceed a SlashCommandAdmin. + */ + suspend fun proceedAdmin( + slashCommand: SlashCommand.SlashCommandAdmin, + ): Result +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt new file mode 100644 index 0000000000..5a826d5fbd --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.api + +data class SlashCommandSuggestion( + val command: String, + val parameters: String?, + val description: String, +) diff --git a/libraries/slashcommands/impl/build.gradle.kts b/libraries/slashcommands/impl/build.gradle.kts new file mode 100644 index 0000000000..34dc2e42b2 --- /dev/null +++ b/libraries/slashcommands/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.slashcommands.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + api(projects.libraries.slashcommands.api) + implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) + implementation(projects.services.toolbox.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt new file mode 100644 index 0000000000..0b7b58a15f --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import androidx.annotation.StringRes + +/** + * Defines the command line operations. + * The user can write these messages to perform some actions. + * The list will be displayed in this order. + */ +enum class Command( + val command: String, + val aliases: List? = null, + val parameters: String? = null, + @StringRes val description: Int, + val isAllowedInThread: Boolean = true, + val isSupported: Boolean = true, + val isDevCommand: Boolean = false, +) { + CRASH_APP( + command = "/crash", + description = R.string.slash_command_description_crash_application, + isDevCommand = true, + ), + EMOTE( + command = "/me", + parameters = "", + description = R.string.slash_command_description_emote, + ), + BAN_USER( + command = "/ban", + parameters = " [reason]", + description = R.string.slash_command_description_ban_user, + ), + UNBAN_USER( + command = "/unban", + parameters = " [reason]", + description = R.string.slash_command_description_unban_user, + ), + IGNORE_USER( + command = "/ignore", + parameters = " [reason]", + description = R.string.slash_command_description_ignore_user, + ), + UNIGNORE_USER( + command = "/unignore", + parameters = "", + description = R.string.slash_command_description_unignore_user, + ), + SET_USER_POWER_LEVEL( + command = "/op", + parameters = " []", + description = R.string.slash_command_description_op_user, + isAllowedInThread = false, + isSupported = false, + ), + RESET_USER_POWER_LEVEL( + command = "/deop", + parameters = "", + description = R.string.slash_command_description_deop_user, + isAllowedInThread = false, + isSupported = false, + ), + ROOM_NAME( + command = "/roomname", + parameters = "", + description = R.string.slash_command_description_room_name, + isAllowedInThread = false, + ), + INVITE( + command = "/invite", + parameters = " [reason]", + description = R.string.slash_command_description_invite_user, + ), + JOIN_ROOM( + command = "/join", + aliases = listOf("/j", "/goto"), + parameters = " [reason]", + description = R.string.slash_command_description_join_room, + isAllowedInThread = false, + isSupported = false, + ), + TOPIC( + command = "/topic", + parameters = "", + description = R.string.slash_command_description_topic, + isAllowedInThread = false, + ), + REMOVE_USER( + command = "/remove", + aliases = listOf("/kick"), + parameters = " [reason]", + description = R.string.slash_command_description_remove_user, + ), + CHANGE_DISPLAY_NAME( + command = "/nick", + parameters = "", + description = R.string.slash_command_description_nick, + ), + CHANGE_DISPLAY_NAME_FOR_ROOM( + command = "/myroomnick", + aliases = listOf("/roomnick"), + parameters = "", + description = R.string.slash_command_description_nick_for_room, + isAllowedInThread = false, + isSupported = false, + ), + ROOM_AVATAR( + command = "/roomavatar", + parameters = "", + description = R.string.slash_command_description_room_avatar, + isAllowedInThread = false, + // Dev command since user has to know the mxc url + isDevCommand = true, + isSupported = false, + ), + CHANGE_AVATAR_FOR_ROOM( + command = "/myroomavatar", + parameters = "", + description = R.string.slash_command_description_avatar_for_room, + isAllowedInThread = false, + // Dev command since user has to know the mxc url + isDevCommand = true, + isSupported = false, + ), + RAINBOW( + command = "/rainbow", + parameters = "", + description = R.string.slash_command_description_rainbow, + ), + RAINBOW_EMOTE( + command = "/rainbowme", + parameters = "", + description = R.string.slash_command_description_rainbow_emote, + ), + DEVTOOLS( + command = "/devtools", + description = R.string.slash_command_description_devtools, + isDevCommand = true, + ), + SPOILER( + command = "/spoiler", + parameters = "", + description = R.string.slash_command_description_spoiler, + ), + SHRUG( + command = "/shrug", + parameters = "", + description = R.string.slash_command_description_shrug, + ), + LENNY( + command = "/lenny", + parameters = "", + description = R.string.slash_command_description_lenny, + ), + PLAIN( + command = "/plain", + parameters = "", + description = R.string.slash_command_description_plain, + ), + WHOIS( + command = "/whois", + parameters = "", + description = R.string.slash_command_description_whois, + ), + DISCARD_SESSION( + command = "/discardsession", + description = R.string.slash_command_description_discard_session, + isAllowedInThread = false, + isSupported = false, + ), + CONFETTI( + command = "/confetti", + parameters = "", + description = R.string.slash_command_confetti, + isAllowedInThread = false, + isSupported = false, + ), + SNOWFALL( + command = "/snowfall", + parameters = "", + description = R.string.slash_command_snow, + isAllowedInThread = false, + isSupported = false, + ), + LEAVE_ROOM( + command = "/leave", + aliases = listOf("/part"), + description = R.string.slash_command_description_leave_room, + isAllowedInThread = false, + isDevCommand = true, + ), + UPGRADE_ROOM( + command = "/upgraderoom", + parameters = "newVersion", + description = R.string.slash_command_description_upgrade_room, + isAllowedInThread = false, + isDevCommand = true, + isSupported = false, + ), + TABLE_FLIP( + command = "/tableflip", + parameters = "", + description = R.string.slash_command_description_table_flip, + ), + UNFLIP( + command = "/unflip", + parameters = "", + description = R.string.slash_command_description_unflip, + ); + + val allAliases = listOf(command) + aliases.orEmpty() + + /** + * Checks if the input command matches any of the command aliases, ignoring case. + * Do not exclude not supported commands so that user can discover that the command is not supported. + * Used for whole command parsing. + */ + fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) } + + /** + * Checks if the input is a prefix of any of the command aliases, ignoring the first character (the slash), and excluding not supported command. + * Used for suggestions. + */ + fun startsWith(input: CharSequence) = isSupported && + allAliases.any { it.startsWith(input, 1, true) } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt new file mode 100644 index 0000000000..0acd3af6f8 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.MsgType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.slashcommands.api.MessagePrefix +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator +import io.element.android.services.toolbox.api.strings.StringProvider + +@Inject +class CommandExecutor( + private val matrixClient: MatrixClient, + private val joinedRoom: JoinedRoom, + private val rainbowGenerator: RainbowGenerator, + private val stringProvider: StringProvider, +) { + suspend fun proceedSendMessage( + slashCommand: SlashCommand.SlashCommandSendMessage, + timeline: Timeline, + ): Result { + return when (slashCommand) { + is SlashCommand.SendChatEffect -> sendChatEffect() + is SlashCommand.SendEmote -> sendEmote(slashCommand, timeline) + is SlashCommand.SendWithPrefix -> sendPrefixedMessage(slashCommand.prefix, slashCommand.message, timeline) + is SlashCommand.SendPlainText -> sendPlainText(slashCommand, timeline) + is SlashCommand.SendRainbow -> sendRainbow(slashCommand, timeline) + is SlashCommand.SendRainbowEmote -> sendRainbowEmote(slashCommand, timeline) + is SlashCommand.SendSpoiler -> sendSpoiler(slashCommand, timeline) + } + } + + suspend fun proceedAdmin( + slashCommand: SlashCommand.SlashCommandAdmin, + ): Result { + return when (slashCommand) { + is SlashCommand.BanUser -> banUser(slashCommand) + is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom() + is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand) + is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom() + is SlashCommand.ChangeRoomAvatar -> changeRoomAvatar() + is SlashCommand.ChangeRoomName -> changeRoomName(slashCommand) + is SlashCommand.ChangeTopic -> changeTopic(slashCommand) + is SlashCommand.DiscardSession -> discardSession() + is SlashCommand.IgnoreUser -> ignoreUser(slashCommand) + is SlashCommand.Invite -> invite(slashCommand) + is SlashCommand.JoinRoom -> joinRoom(slashCommand) + is SlashCommand.LeaveRoom -> leaveRoom(joinedRoom) + is SlashCommand.RemoveUser -> removeUser(slashCommand) + is SlashCommand.SetUserPowerLevel -> setUserPowerLevel() + is SlashCommand.UnbanUser -> unbanUser(slashCommand) + is SlashCommand.UnignoreUser -> unignoreUser(slashCommand) + is SlashCommand.UpgradeRoom -> upgradeRoom() + } + } + + private fun upgradeRoom(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result { + return matrixClient.unignoreUser(slashCommand.userId) + } + + private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result { + return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason) + } + + private fun setUserPowerLevel(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result { + val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})" + val formattedText = "${slashCommand.message}" + return timeline.sendMessage( + body = text, + htmlBody = formattedText, + intentionalMentions = emptyList(), + ) + } + + private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result { + val message = slashCommand.message.toString() + return timeline.sendMessage( + body = message, + htmlBody = rainbowGenerator.generate(message), + msgType = MsgType.MSG_TYPE_EMOTE, + intentionalMentions = emptyList(), + ) + } + + private suspend fun sendRainbow(slashCommand: SlashCommand.SendRainbow, timeline: Timeline): Result { + val message = slashCommand.message.toString() + return timeline.sendMessage( + body = message, + htmlBody = rainbowGenerator.generate(message), + intentionalMentions = emptyList(), + ) + } + + private suspend fun sendPlainText(slashCommand: SlashCommand.SendPlainText, timeline: Timeline): Result { + return timeline.sendMessage( + body = slashCommand.message.toString(), + htmlBody = null, + intentionalMentions = emptyList(), + asPlainText = true, + ) + } + + private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result { + val message = slashCommand.message.toString() + return timeline.sendMessage( + body = message, + htmlBody = null, + msgType = MsgType.MSG_TYPE_EMOTE, + intentionalMentions = emptyList(), + ) + } + + private fun sendChatEffect(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result { + return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason) + } + + private suspend fun leaveRoom( + room: JoinedRoom, + ): Result { + return room.leave() + } + + private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result { + return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList()) + .map {} + } + + private suspend fun invite(slashCommand: SlashCommand.Invite): Result { + return joinedRoom.inviteUserById(slashCommand.userId) + } + + private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result { + return matrixClient.ignoreUser(slashCommand.userId) + } + + private fun discardSession(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result { + return joinedRoom.setTopic(slashCommand.topic) + } + + private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result { + return joinedRoom.setName(slashCommand.name) + } + + private fun changeRoomAvatar(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private fun changeDisplayNameForRoom(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result { + return matrixClient.setDisplayName(slashCommand.displayName) + } + + private fun changeAvatarForRoom(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result { + return joinedRoom.banUser(slashCommand.userId, slashCommand.reason) + } + + private suspend fun sendPrefixedMessage( + prefix: MessagePrefix, + message: CharSequence, + timeline: Timeline, + ): Result { + val sequence = buildString { + append(prefix.toMarkdown()) + if (message.isNotEmpty()) { + append(" ") + append(message) + } + } + return timeline.sendMessage( + body = sequence, + htmlBody = null, + intentionalMentions = emptyList(), + ) + } +} + +private fun MessagePrefix.toMarkdown() = when (this) { + MessagePrefix.Shrug -> "¯\\\\_(ツ)\\_/¯" + MessagePrefix.TableFlip -> "(╯°□°)╯︵ ┻━┻" + MessagePrefix.Unflip -> "┬──┬ ノ( ゜-゜ノ)" + MessagePrefix.Lenny -> "( ͡° ͜ʖ ͡°)" +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt new file mode 100644 index 0000000000..85a045f50c --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.mxc.isMxcUrl +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.slashcommands.api.ChatEffect +import io.element.android.libraries.slashcommands.api.MessagePrefix +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.flow.first +import timber.log.Timber + +@Inject +class CommandParser( + private val appPreferencesStore: AppPreferencesStore, + private val featureFlagService: FeatureFlagService, + private val stringProvider: StringProvider, +) { + /** + * Convert the text message into a Slash command. + * + * @param textMessage the text message in plain text + * @param formattedMessage the text messaged in HTML format + * @param isInThreadTimeline true if the user is currently typing in a thread + * @return a parsed slash command (ok or error) + */ + suspend fun parseSlashCommand( + textMessage: CharSequence, + formattedMessage: String?, + isInThreadTimeline: Boolean, + ): SlashCommand { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) { + return SlashCommand.NotACommand + } + // check if it has the Slash marker + val message = formattedMessage ?: textMessage + return if (!message.startsWith("/")) { + SlashCommand.NotACommand + } else { + // "/" only + if (message.length == 1) { + return SlashCommand.ErrorEmptySlashCommand( + stringProvider.getString(R.string.slash_command_unrecognized, "/") + ) + } + // Exclude "//" + if ("/" == message.substring(1, 2)) { + return SlashCommand.NotACommand + } + val (messageParts, message) = extractMessage(message.toString()) + ?: return SlashCommand.ErrorEmptySlashCommand( + stringProvider.getString(R.string.slash_command_unrecognized, "/") + ) + val slashCommand = messageParts.first() + getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { + return SlashCommand.ErrorCommandNotSupportedInThreads( + stringProvider.getString( + R.string.slash_command_not_supported_in_threads, + it.command, + ) + ) + } + when { + Command.PLAIN.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.SendPlainText(message = message) + } else { + syntaxError(Command.PLAIN) + } + } + Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.ChangeDisplayName(displayName = message) + } else { + syntaxError(Command.CHANGE_DISPLAY_NAME) + } + } + Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.ChangeDisplayNameForRoom(displayName = message) + } else { + syntaxError(Command.CHANGE_DISPLAY_NAME_FOR_ROOM) + } + } + Command.ROOM_AVATAR.matches(slashCommand) -> { + if (messageParts.size == 2) { + val url = messageParts[1] + if (url.isMxcUrl()) { + SlashCommand.ChangeRoomAvatar(url) + } else { + syntaxError(Command.ROOM_AVATAR) + } + } else { + syntaxError(Command.ROOM_AVATAR) + } + } + Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> { + if (messageParts.size == 2) { + val url = messageParts[1] + + if (url.isMxcUrl()) { + SlashCommand.ChangeAvatarForRoom(url) + } else { + syntaxError(Command.CHANGE_AVATAR_FOR_ROOM) + } + } else { + syntaxError(Command.CHANGE_AVATAR_FOR_ROOM) + } + } + Command.TOPIC.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.ChangeTopic(topic = message) + } else { + syntaxError(Command.TOPIC) + } + } + Command.EMOTE.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.SendEmote(message) + } else { + syntaxError(Command.EMOTE) + } + } + Command.RAINBOW.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.SendRainbow(message) + } else { + syntaxError(Command.RAINBOW) + } + } + Command.RAINBOW_EMOTE.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.SendRainbowEmote(message) + } else { + syntaxError(Command.RAINBOW_EMOTE) + } + } + Command.JOIN_ROOM.matches(slashCommand) -> { + if (messageParts.size >= 2) { + val id = messageParts[1] + val roomIdOrAlias = RoomIdOrAlias.from(id) + if (roomIdOrAlias != null) { + SlashCommand.JoinRoom( + RoomIdOrAlias.Id(RoomId(id)), + trimParts(textMessage, messageParts.take(2)) + ) + } else { + syntaxError(Command.JOIN_ROOM) + } + } else { + syntaxError(Command.JOIN_ROOM) + } + } + Command.ROOM_NAME.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.ChangeRoomName(name = message) + } else { + syntaxError(Command.ROOM_NAME) + } + } + Command.INVITE.matches(slashCommand) -> { + if (messageParts.size >= 2) { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.Invite( + userId = userId, + reason = trimParts(textMessage, messageParts.take(2)) + ) + } + ?: syntaxError(Command.INVITE) + } else { + syntaxError(Command.INVITE) + } + } + Command.REMOVE_USER.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.RemoveUser( + userId = userId, + reason = trimParts(textMessage, messageParts.take(2)) + ) + } + ?: syntaxError(Command.REMOVE_USER) + } + Command.BAN_USER.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.BanUser( + userId = userId, + reason = trimParts(textMessage, messageParts.take(2)) + ) + } + ?: syntaxError(Command.BAN_USER) + } + Command.UNBAN_USER.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.UnbanUser( + userId = userId, + reason = trimParts(textMessage, messageParts.take(2)) + ) + } + ?: syntaxError(Command.UNBAN_USER) + } + Command.IGNORE_USER.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.IgnoreUser( + userId = userId, + ) + } + ?: syntaxError(Command.IGNORE_USER) + } + Command.UNIGNORE_USER.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.UnignoreUser( + userId = userId, + ) + } + ?: syntaxError(Command.UNIGNORE_USER) + } + Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> { + if (messageParts.size == 3) { + val userId = parseUserId(messageParts) + if (userId != null) { + val powerLevelsAsString = messageParts[2] + try { + val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString) + SlashCommand.SetUserPowerLevel( + userId = userId, + powerLevel = powerLevelsAsInt + ) + } catch (_: Exception) { + syntaxError(Command.SET_USER_POWER_LEVEL) + } + } else { + syntaxError(Command.SET_USER_POWER_LEVEL) + } + } else { + syntaxError(Command.SET_USER_POWER_LEVEL) + } + } + Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.SetUserPowerLevel( + userId = userId, + powerLevel = null + ) + } + ?: syntaxError(Command.SET_USER_POWER_LEVEL) + } + Command.DEVTOOLS.matches(slashCommand) -> { + if (messageParts.size == 1) { + SlashCommand.DevTools + } else { + syntaxError(Command.DEVTOOLS) + } + } + Command.SPOILER.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.SendSpoiler(message) + } else { + syntaxError(Command.SPOILER) + } + } + Command.SHRUG.matches(slashCommand) -> { + SlashCommand.SendWithPrefix(MessagePrefix.Shrug, message) + } + Command.LENNY.matches(slashCommand) -> { + SlashCommand.SendWithPrefix(MessagePrefix.Lenny, message) + } + Command.TABLE_FLIP.matches(slashCommand) -> { + SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, message) + } + Command.UNFLIP.matches(slashCommand) -> { + SlashCommand.SendWithPrefix(MessagePrefix.Unflip, message) + } + Command.DISCARD_SESSION.matches(slashCommand) -> { + if (messageParts.size == 1) { + SlashCommand.DiscardSession + } else { + syntaxError(Command.DISCARD_SESSION) + } + } + Command.WHOIS.matches(slashCommand) -> { + parseUserId(messageParts) + ?.let { userId -> + SlashCommand.ShowUser( + userId = userId, + ) + } + ?: syntaxError(Command.WHOIS) + } + Command.CONFETTI.matches(slashCommand) -> { + SlashCommand.SendChatEffect(ChatEffect.CONFETTI, message) + } + Command.SNOWFALL.matches(slashCommand) -> { + SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, message) + } + Command.LEAVE_ROOM.matches(slashCommand) -> { + if (messageParts.size == 1) { + SlashCommand.LeaveRoom + } else { + syntaxError(Command.LEAVE_ROOM) + } + } + Command.UPGRADE_ROOM.matches(slashCommand) -> { + if (message.isNotEmpty()) { + SlashCommand.UpgradeRoom(newVersion = message) + } else { + syntaxError(Command.UPGRADE_ROOM) + } + } + Command.CRASH_APP.matches(slashCommand) && appPreferencesStore.isDeveloperModeEnabledFlow().first() -> { + error("Application crashed from user demand") + } + else -> { + // Unknown command + SlashCommand.ErrorUnknownSlashCommand( + stringProvider.getString(R.string.slash_command_unrecognized, slashCommand) + ) + } + } + } + } + + private fun parseUserId(messageParts: List): UserId? { + val str = messageParts.getOrNull(1) ?: return null + return when { + MatrixPatterns.isUserId(str) -> str + str == " { + // Rich text editor mode + messageParts.getOrNull(2)?.let { html -> + // html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org" + val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)".toRegex() + val matchResult = regex.find(html) + val userId = matchResult?.groupValues?.getOrNull(1) + userId?.takeIf { + userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it) + } + } + } + else -> { + // Can be markdown format like "[@user:domain.org](https://matrix.to/#/@user:domain.org)" + val regex = "\\[([^\\]]+)]\\(https://matrix.to/#/([^\\]]+)\\)".toRegex() + val matchResult = regex.find(str) + val userId = matchResult?.groupValues?.getOrNull(1) + userId?.takeIf { + userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it) + } + } + } + ?.let(::UserId) + } + + private fun syntaxError(command: Command) = SlashCommand.ErrorSyntax( + stringProvider.getString( + R.string.slash_command_parameters_error, + command.command, + buildString { + append(command.command) + if (command.parameters != null) { + append(" ${command.parameters}") + } + }, + ) + ) + + private fun extractMessage(message: String): Pair, String>? { + val messageParts = try { + message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } + } catch (e: Exception) { + Timber.e(e, "## parseSlashCommand() : split failed") + null + } + + // test if the string cut fails + if (messageParts.isNullOrEmpty()) { + return null + } + + val slashCommand = messageParts.first() + val trimmedMessage = message.substring(slashCommand.length).trim() + + return messageParts to trimmedMessage + } + + private val notSupportedThreadsCommands: List by lazy { + Command.entries.filter { + !it.isAllowedInThread + } + } + + /** + * Checks whether the current command is not supported by threads. + * @param isInThreadTimeline if its true we are in a thread timeline + * @param slashCommand the slash command that will be checked + * @return The command that is not supported + */ + private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? { + return if (isInThreadTimeline) { + notSupportedThreadsCommands.firstOrNull { + it.command == slashCommand + } + } else { + null + } + } + + private fun trimParts(message: CharSequence, messageParts: List): String? { + val partsSize = messageParts.sumOf { it.length } + val gapsNumber = messageParts.size - 1 + return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() } + } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt new file mode 100644 index 0000000000..ba2786c944 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.api.SlashCommandService +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.flow.first + +@ContributesBinding(RoomScope::class) +class DefaultSlashCommandService( + private val commandParser: CommandParser, + private val commandExecutor: CommandExecutor, + private val stringProvider: StringProvider, + private val appPreferencesStore: AppPreferencesStore, + private val featureFlagService: FeatureFlagService, +) : SlashCommandService { + override suspend fun getSuggestions( + text: String, + isInThread: Boolean, + ): List { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList() + val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() + return Command.entries.filter { + it.startsWith(text) + }.filter { + !isInThread || it.isAllowedInThread + }.filter { + !it.isDevCommand || isDeveloperModeEnabled + }.map { + SlashCommandSuggestion( + command = it.command, + parameters = it.parameters, + description = stringProvider.getString(it.description), + ) + } + } + + override suspend fun parse( + textMessage: CharSequence, + formattedMessage: String?, + isInThreadTimeline: Boolean, + ): SlashCommand { + return commandParser.parseSlashCommand( + textMessage = textMessage, + formattedMessage = formattedMessage, + isInThreadTimeline = isInThreadTimeline, + ) + } + + override suspend fun proceedSendMessage( + slashCommand: SlashCommand.SlashCommandSendMessage, + timeline: Timeline, + ): Result { + return commandExecutor.proceedSendMessage( + slashCommand = slashCommand, + timeline = timeline, + ) + } + + override suspend fun proceedAdmin( + slashCommand: SlashCommand.SlashCommandAdmin, + ): Result { + return commandExecutor.proceedAdmin( + slashCommand = slashCommand, + ) + } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt new file mode 100644 index 0000000000..594b51cbf6 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl.rainbow + +import dev.zacsweers.metro.Inject +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin + +/** + * Inspired from React-Sdk + * Ref: https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/utils/colour.js + */ +@Inject +class RainbowGenerator { + fun generate(text: String): String { + val split = text.splitEmoji() + val frequency = 2 * Math.PI / split.size + + return split + .mapIndexed { idx, letter -> + // Do better than React-Sdk: Avoid adding font color for spaces + if (letter == " ") { + "$letter" + } else { + val (a, b) = generateAB(idx * frequency, 1f) + val dashColor = labToRGB(75, a, b).toDashColor() + "$letter" + } + } + .joinToString(separator = "") + } + + private fun generateAB(hue: Double, chroma: Float): Pair { + val a = chroma * 127 * cos(hue) + val b = chroma * 127 * sin(hue) + + return Pair(a, b) + } + + private fun labToRGB(l: Int, a: Double, b: Double): RgbColor { + // Convert CIELAB to CIEXYZ (D65) + var y = (l + 16) / 116.0 + val x = adjustXYZ(y + a / 500) * 0.9505 + val z = adjustXYZ(y - b / 200) * 1.0890 + + y = adjustXYZ(y) + + // Linear transformation from CIEXYZ to RGB + val red = 3.24096994 * x - 1.53738318 * y - 0.49861076 * z + val green = -0.96924364 * x + 1.8759675 * y + 0.04155506 * z + val blue = 0.05563008 * x - 0.20397696 * y + 1.05697151 * z + + return RgbColor(adjustRGB(red), adjustRGB(green), adjustRGB(blue)) + } + + private fun adjustXYZ(value: Double): Double { + if (value > 0.2069) { + return value.pow(3) + } + return 0.1284 * value - 0.01771 + } + + private fun gammaCorrection(value: Double): Double { + // Non-linear transformation to sRGB + if (value <= 0.0031308) { + return 12.92 * value + } + return 1.055 * value.pow(1 / 2.4) - 0.055 + } + + private fun adjustRGB(value: Double): Int { + return (gammaCorrection(value) + .coerceIn(0.0, 1.0) * 255) + .roundToInt() + } +} + +/** + * Same as split, but considering emojis. + */ +private fun CharSequence.splitEmoji(): List { + val result = mutableListOf() + var index = 0 + while (index < length) { + val firstChar = get(index) + if (firstChar.code == 0x200e) { + // Left to right mark. What should I do with it? + } else if (firstChar.code in 0xD800..0xDBFF && index + 1 < length) { + // We have the start of a surrogate pair + val secondChar = get(index + 1) + if (secondChar.code in 0xDC00..0xDFFF) { + // We have an emoji + result.add("$firstChar$secondChar") + index++ + } else { + // Not sure what we have here... + result.add("$firstChar") + } + } else { + // Regular char + result.add("$firstChar") + } + index++ + } + return result +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt new file mode 100644 index 0000000000..c425d81d73 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl.rainbow + +data class RgbColor( + val r: Int, + val g: Int, + val b: Int +) + +fun RgbColor.toDashColor(): String { + return listOf(r, g, b) + .joinToString(separator = "", prefix = "#") { + it.toString(16).padStart(2, '0') + } +} diff --git a/libraries/slashcommands/impl/src/main/res/values/temporary.xml b/libraries/slashcommands/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..0a8f2a0034 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/res/values/temporary.xml @@ -0,0 +1,47 @@ + + + Command error + Unrecognized command: %1$s + The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s + The command \"%1$s\" is recognized but not supported in threads. + Displays action + Crash the application. + Bans user with given id + Unbans user with given id + Ignores a user, hiding their messages from you + Stops ignoring a user, showing their messages going forward + Define the power level of a user + Deops user with given id + Sets the room name + Sends the given message colored as a rainbow + Sends the given emote colored as a rainbow + Invites user with given id to current room + Joins room with given address + Sends the given message as a spoiler + Set the room topic + Removes user with given id from this room + Changes your display nickname + Sends the given message with confetti + Sends the given message with snowfall + Sends a message as plain text, without interpreting it as markdown + Changes your display nickname in the current room only + Changes the avatar of the current room + Changes your avatar in this current room only + Open the developer tools screen + Displays information about a user + Prepends ¯\\_(ツ)_/¯ to a plain-text message + Prepends ( ͡° ͜ʖ ͡°) to a plain-text message + Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message + Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message + Forces the current outbound group session in an encrypted room to be discarded + Only supported in encrypted rooms + Leave the current room + Upgrades a room to a new version + + Spoiler + diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt new file mode 100644 index 0000000000..497f45c96f --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.timeline.MsgType +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.slashcommands.api.ChatEffect +import io.element.android.libraries.slashcommands.api.MessagePrefix +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CommandExecutorTest { + @Test + fun `send plain text delegates to timeline with plain flag`() = runTest { + val timeline = FakeTimeline() + var capturedBody: String? = null + var capturedHtml: String? = "initial" + var capturedAsPlainText = false + timeline.sendMessageLambda = { body, htmlBody, _, _, asPlainText -> + capturedBody = body + capturedHtml = htmlBody + capturedAsPlainText = asPlainText + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendPlainText("hello"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("hello") + assertThat(capturedHtml).isNull() + assertThat(capturedAsPlainText).isTrue() + } + + @Test + fun `send emote delegates to timeline as emote`() = runTest { + val timeline = FakeTimeline() + var msgType: MsgType? = null + timeline.sendMessageLambda = { _, _, _, type, _ -> + msgType = type + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendEmote("yay"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(msgType).isEqualTo(MsgType.MSG_TYPE_EMOTE) + } + + @Test + fun `send lenny prefixes message`() = runTest { + val timeline = FakeTimeline() + var capturedBody: String? = null + timeline.sendMessageLambda = { body, _, _, _, _ -> + capturedBody = body + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("( ͡° ͜ʖ ͡°) fun") + } + + @Test + fun `send table flip prefixes message`() = runTest { + val timeline = FakeTimeline() + var capturedBody: String? = null + timeline.sendMessageLambda = { body, _, _, _, _ -> + capturedBody = body + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("(╯°□°)╯︵ ┻━┻ wow") + } + + @Test + fun `send unflip prefixes message`() = runTest { + val timeline = FakeTimeline() + var capturedBody: String? = null + timeline.sendMessageLambda = { body, _, _, _, _ -> + capturedBody = body + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "keep cool"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("┬──┬ ノ( ゜-゜ノ) keep cool") + } + + @Test + fun `send shrug prefixes message`() = runTest { + val timeline = FakeTimeline() + var capturedBody: String? = null + timeline.sendMessageLambda = { body, _, _, _, _ -> + capturedBody = body + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "wow"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("¯\\\\_(ツ)\\_/¯ wow") + } + + @Test + fun `send rainbow provides html body`() = runTest { + val timeline = FakeTimeline() + var capturedHtml: String? = null + var capturedBody: String? = null + var capturedMsgType: MsgType? = null + timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ -> + capturedBody = body + capturedHtml = htmlBody + capturedMsgType = msgType + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendRainbow("a nice rainbow"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("a nice rainbow") + assertThat(capturedHtml).isNotNull() + assertThat(capturedHtml!!.contains(" + capturedBody = body + capturedHtml = htmlBody + capturedMsgType = msgType + Result.success(Unit) + } + val sut = createCommandExecutor() + val res = sut.proceedSendMessage(SlashCommand.SendRainbowEmote("a nice rainbow"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("a nice rainbow") + assertThat(capturedHtml).isNotNull() + assertThat(capturedHtml!!.contains(" + capturedBody = body + capturedHtml = htmlBody + Result.success(Unit) + } + val stringProvider = FakeStringProvider(defaultResult = "SPOILER") + val sut = createCommandExecutor( + stringProvider = stringProvider, + ) + val res = sut.proceedSendMessage(SlashCommand.SendSpoiler("secret"), timeline) + assertThat(res.isSuccess).isTrue() + assertThat(capturedBody).isEqualTo("[SPOILER](secret)") + assertThat(capturedHtml).isEqualTo("secret") + } + + @Test + fun `send chat effect is not supported`() = runTest { + val sut = createCommandExecutor() + val res = sut.proceedSendMessage( + SlashCommand.SendChatEffect(ChatEffect.CONFETTI, A_MESSAGE), + FakeTimeline() + ) + assertThat(res.isFailure).isTrue() + } + + @Test + fun `admin commands call underlying client and room APIs`() = runTest { + var kicked = false + var banned = false + var unbanned = false + var invited = false + var ignored = false + var unignored = false + var left = false + var topicSet = false + var nameSet = false + var joined = false + + val joinedRoom = FakeJoinedRoom( + kickUserResult = { _, _ -> + kicked = true + Result.success(Unit) + }, + banUserResult = { _, _ -> + banned = true + Result.success(Unit) + }, + unBanUserResult = { _, _ -> + unbanned = true + Result.success(Unit) + }, + inviteUserResult = { _ -> + invited = true + Result.success(Unit) + }, + setTopicResult = { _ -> + topicSet = true + Result.success(Unit) + }, + setNameResult = { _ -> + nameSet = true + Result.success(Unit) + }, + baseRoom = FakeBaseRoom( + leaveRoomLambda = { + left = true + Result.success(Unit) + }, + ) + ) + val matrixClient = FakeMatrixClient( + ignoreUserResult = { _ -> + ignored = true + Result.success(Unit) + }, + unIgnoreUserResult = { _ -> + unignored = true + Result.success(Unit) + }, + ).apply { + joinRoomByIdOrAliasLambda = { _, _ -> + joined = true + Result.success(null) + } + } + val sut = createCommandExecutor( + matrixClient = matrixClient, + joinedRoom = joinedRoom, + ) + val kickRes = sut.proceedAdmin(SlashCommand.RemoveUser(A_USER_ID, null)) + assertThat(kicked).isTrue() + assertThat(kickRes.isSuccess).isTrue() + val banRes = sut.proceedAdmin(SlashCommand.BanUser(A_USER_ID, "reason")) + assertThat(banned).isTrue() + assertThat(banRes.isSuccess).isTrue() + val unbanRes = sut.proceedAdmin(SlashCommand.UnbanUser(A_USER_ID, null)) + assertThat(unbanned).isTrue() + assertThat(unbanRes.isSuccess).isTrue() + val inviteRes = sut.proceedAdmin(SlashCommand.Invite(A_USER_ID, null)) + assertThat(invited).isTrue() + assertThat(inviteRes.isSuccess).isTrue() + val ignoreRes = sut.proceedAdmin(SlashCommand.IgnoreUser(A_USER_ID)) + assertThat(ignoreRes.isSuccess).isTrue() + assertThat(ignored).isTrue() + val unignoreRes = sut.proceedAdmin(SlashCommand.UnignoreUser(A_USER_ID)) + assertThat(unignoreRes.isSuccess).isTrue() + assertThat(unignored).isTrue() + val leaveRes = sut.proceedAdmin(SlashCommand.LeaveRoom) + assertThat(leaveRes.isSuccess).isTrue() + assertThat(left).isTrue() + val topicRes = sut.proceedAdmin(SlashCommand.ChangeTopic("t")) + assertThat(topicRes.isSuccess).isTrue() + assertThat(topicSet).isTrue() + val nameRes = sut.proceedAdmin(SlashCommand.ChangeRoomName("n")) + assertThat(nameRes.isSuccess).isTrue() + assertThat(nameSet).isTrue() + val joinRes = sut.proceedAdmin( + SlashCommand.JoinRoom( + roomIdOrAlias = RoomIdOrAlias.Id( + RoomId("!room:domain") + ), + reason = null, + ) + ) + assertThat(joinRes.isSuccess).isTrue() + assertThat(joined).isTrue() + } +} + +fun createCommandExecutor( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + joinedRoom: FakeJoinedRoom = FakeJoinedRoom(), + rainbowGenerator: RainbowGenerator = RainbowGenerator(), + stringProvider: StringProvider = FakeStringProvider(), +) = CommandExecutor( + matrixClient = matrixClient, + joinedRoom = joinedRoom, + rainbowGenerator = rainbowGenerator, + stringProvider = stringProvider, +) diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt new file mode 100644 index 0000000000..0887847a40 --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.slashcommands.api.ChatEffect +import io.element.android.libraries.slashcommands.api.MessagePrefix +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CommandParserTest { + @Test + fun parseSlashCommandEmpty() = runTest { + test("/", SlashCommand.ErrorEmptySlashCommand("A string/")) + } + + @Test + fun parseSlashCommandUnknown() = runTest { + test("/unknown", SlashCommand.ErrorUnknownSlashCommand("A string/unknown")) + test("/unknown with param", SlashCommand.ErrorUnknownSlashCommand("A string/unknown")) + } + + @Test + fun parseSlashCommandNotACommand() = runTest { + test("", SlashCommand.NotACommand) + test("test", SlashCommand.NotACommand) + test("// test", SlashCommand.NotACommand) + } + + @Test + fun parseSlashCommandEmote() = runTest { + test("/me test", SlashCommand.SendEmote("test")) + test("/me", SlashCommand.ErrorSyntax("A string/me, /me ")) + } + + @Test + fun parseSlashCommandRemove() = runTest { + // Nominal + test("/remove $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null)) + // With a reason + test("/remove $A_USER_ID a reason", SlashCommand.RemoveUser(A_USER_ID, "a reason")) + // Trim the reason + test("/remove $A_USER_ID a reason ", SlashCommand.RemoveUser(A_USER_ID, "a reason")) + // Alias + test("/kick $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null)) + // Error + test("/remove", SlashCommand.ErrorSyntax("A string/remove, /remove [reason]")) + } + + @Test + fun parseSlashCommandRemoveMarkdown() = runTest { + // Nominal + test( + "/remove [@user:domain.org](https://matrix.to/#/@user:domain.org)", + SlashCommand.RemoveUser(UserId("@user:domain.org"), null) + ) + test( + "/remove [@user:domain.org](https://matrix.to/#/@user:domain.org) reason", + SlashCommand.RemoveUser(UserId("@user:domain.org"), "reason") + ) + } + + @Test + fun parseSlashCommandPlainAndNick() = runTest { + test("/plain hello", SlashCommand.SendPlainText("hello")) + test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain ")) + + test("/nick John", SlashCommand.ChangeDisplayName("John")) + test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick ")) + } + + @Test + fun parseSlashCommandRoomNickAndAvatars() = runTest { + test("/myroomnick Roomy", SlashCommand.ChangeDisplayNameForRoom("Roomy")) + test("/roomavatar mxc://matrix.org/abc", SlashCommand.ChangeRoomAvatar("mxc://matrix.org/abc")) + test("/roomavatar http://notmxc", SlashCommand.ErrorSyntax("A string/roomavatar, /roomavatar ")) + test("/myroomavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatarForRoom("mxc://matrix.org/abc")) + } + + @Test + fun parseSlashCommandTopicAndRainbow() = runTest { + test("/topic New topic", SlashCommand.ChangeTopic("New topic")) + test("/topic", SlashCommand.ErrorSyntax("A string/topic, /topic ")) + + test("/rainbow yay", SlashCommand.SendRainbow("yay")) + test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow ")) + + test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay")) + test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme ")) + } + + @Test + fun parseSlashCommandJoinAndRoomName() = runTest { + // valid join + test( + "/join !roomId:domain reason", + SlashCommand.JoinRoom( + RoomIdOrAlias.Id(RoomId("!roomId:domain")), + "reason" + ) + ) + + // invalid join + test("/join notavalid", SlashCommand.ErrorSyntax("A string/join, /join [reason]")) + + test("/roomname My Room", SlashCommand.ChangeRoomName("My Room")) + test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname ")) + } + + @Test + fun parseSlashCommandInviteBanEtc() = runTest { + test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null)) + test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite [reason]")) + + test("/ban $A_USER_ID bad", SlashCommand.BanUser(A_USER_ID, "bad")) + test("/unban $A_USER_ID", SlashCommand.UnbanUser(A_USER_ID, null)) + + test("/ignore $A_USER_ID", SlashCommand.IgnoreUser(A_USER_ID)) + test("/unignore $A_USER_ID", SlashCommand.UnignoreUser(A_USER_ID)) + } + + @Test + fun parseSlashCommandPowerLevels() = runTest { + test("/op $A_USER_ID 50", SlashCommand.SetUserPowerLevel(A_USER_ID, 50)) + test("/op $A_USER_ID notnumber", SlashCommand.ErrorSyntax("A string/op, /op []")) + test("/deop $A_USER_ID", SlashCommand.SetUserPowerLevel(A_USER_ID, null)) + } + + @Test + fun parseSlashCommandDevtoolsAndSpoiler() = runTest { + test("/devtools", SlashCommand.DevTools) + test("/devtools extra", SlashCommand.ErrorSyntax("A string/devtools, /devtools")) + + test("/spoiler secret", SlashCommand.SendSpoiler("secret")) + test("/spoiler", SlashCommand.ErrorSyntax("A string/spoiler, /spoiler ")) + } + + @Test + fun parseSlashCommandEmojisAndSession() = runTest { + test("/shrug hello", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "hello")) + test("/shrug", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "")) + + test("/lenny fun", SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun")) + test("/tableflip wow", SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow")) + test("/unflip be safe", SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "be safe")) + + test("/discardsession", SlashCommand.DiscardSession) + test("/discardsession extra", SlashCommand.ErrorSyntax("A string/discardsession, /discardsession")) + } + + @Test + fun parseSlashCommandWhoisAndEffectsAndLeave() = runTest { + test("/whois $A_USER_ID", SlashCommand.ShowUser(A_USER_ID)) + + test("/confetti party", SlashCommand.SendChatEffect(ChatEffect.CONFETTI, "party")) + test("/snowfall snow", SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, "snow")) + + test("/leave", SlashCommand.LeaveRoom) + test("/leave now", SlashCommand.ErrorSyntax("A string/leave, /leave")) + } + + @Test + fun parseSlashCommandUpgradeAndCrashAndFeatureFlagAndThreads() = runTest { + test("/upgraderoom 9", SlashCommand.UpgradeRoom("9")) + test("/upgraderoom", SlashCommand.ErrorSyntax("A string/upgraderoom, /upgraderoom newVersion")) + + // Crash only when developer mode enabled + val cpDev = createCommandParser(appPreferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true)) + try { + cpDev.parseSlashCommand("/crash", null, false) + org.junit.Assert.fail("Expected crash to throw") + } catch (_: IllegalStateException) { + // expected + } + + // Feature flag disabled + val cpFF = createCommandParser(featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SlashCommand.key to false))) + val res = cpFF.parseSlashCommand("/me test", null, false) + assertThat(res).isEqualTo(SlashCommand.NotACommand) + + // Not supported in threads (e.g. /join) + val cpThread = createCommandParser() + val threadRes = cpThread.parseSlashCommand("/join !roomId:domain", null, true) + assertThat(threadRes).isInstanceOf(SlashCommand.ErrorCommandNotSupportedInThreads::class.java) + assertThat((threadRes as SlashCommand.ErrorCommandNotSupportedInThreads).message).isEqualTo("A string/join") + } + + private suspend fun test(message: String, expectedResult: SlashCommand) { + val commandParser = createCommandParser() + val result = commandParser.parseSlashCommand(message, null, false) + assertThat(result).isEqualTo(expectedResult) + } +} + +internal fun createCommandParser( + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SlashCommand.key to true, + ), + ), + stringProvider: StringProvider = FakeStringProvider(), +) = CommandParser( + appPreferencesStore = appPreferencesStore, + featureFlagService = featureFlagService, + stringProvider = stringProvider, +) diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt new file mode 100644 index 0000000000..243f25666c --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.timeline.MsgType +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultSlashCommandServiceTest { + @Test + fun `getSuggestions filters by text and maps to suggestions`() = runTest { + val stringProvider = FakeStringProvider(defaultResult = "desc") + val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false) + val sut = createDefaultSlashCommandService( + commandParser = CommandParser( + appPreferencesStore = prefs, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SlashCommand.key to true, + ) + ), + stringProvider = stringProvider, + ), + stringProvider = stringProvider, + appPreferencesStore = prefs, + ) + val res = sut.getSuggestions("ra", isInThread = true) + // Expect commands starting with "/ra" (case-insensitive) and that are allowed in threads + assertThat(res).isNotEmpty() + assertThat(res.first().description).isEqualTo("desc") + } + + @Test + fun `getSuggestions hides dev commands when developer mode disabled`() = runTest { + val stringProvider = FakeStringProvider() + val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false) + val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider) + val all = sut.getSuggestions("crash", isInThread = true) + assertThat(all).isEmpty() + } + + @Test + fun `getSuggestions returns empty list when the feature is enabled`() = runTest { + val sut = createDefaultSlashCommandService(isFeatureEnabled = true) + val all = sut.getSuggestions("me", isInThread = false) + assertThat(all).isNotEmpty() + } + + @Test + fun `getSuggestions returns empty list when the feature is disabled`() = runTest { + val sut = createDefaultSlashCommandService(isFeatureEnabled = false) + val all = sut.getSuggestions("me", isInThread = false) + assertThat(all).isEmpty() + } + + @Test + fun `getSuggestions for aliases`() = runTest { + val stringProvider = FakeStringProvider() + val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false) + val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider) + val all = sut.getSuggestions("part", isInThread = true) + assertThat(all).isEmpty() + } + + @Test + fun `getSuggestions shows dev commands when developer mode enabled`() = runTest { + val stringProvider = FakeStringProvider() + val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true) + val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider) + val all = sut.getSuggestions("crash", isInThread = true) + assertThat(all).isNotEmpty() + assertThat(all.first().command).isEqualTo("/crash") + } + + @Test + fun `parse delegates to commandParser`() = runTest { + val sut = createDefaultSlashCommandService() + val res = sut.parse("test", null, false) + assertThat(res).isEqualTo(SlashCommand.NotACommand) + } + + @Test + fun `proceedSendMessage delegate to commandExecutor`() = runTest { + val sendMessage = lambdaRecorder { _: String, _: String?, _: List, _: MsgType, _: Boolean -> + Result.success(Unit) + } + val sut = createDefaultSlashCommandService() + val sendRes = sut.proceedSendMessage( + slashCommand = SlashCommand.SendPlainText("hi"), + timeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + }, + ) + assertThat(sendRes.isSuccess).isTrue() + sendMessage.assertions().isCalledOnce() + } + + @Test + fun `proceedAdmin delegates to commandExecutor`() = runTest { + val leaveRoomLambda = lambdaRecorder> { + Result.success(Unit) + } + val sut = createDefaultSlashCommandService( + commandExecutor = CommandExecutor( + matrixClient = FakeMatrixClient(), + joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + leaveRoomLambda = leaveRoomLambda + ), + ), + rainbowGenerator = RainbowGenerator(), + stringProvider = FakeStringProvider(), + ), + ) + val adminRes = sut.proceedAdmin(SlashCommand.LeaveRoom) + assertThat(adminRes.isSuccess).isTrue() + leaveRoomLambda.assertions().isCalledOnce() + } + + private fun createDefaultSlashCommandService( + isFeatureEnabled: Boolean = true, + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SlashCommand.key to isFeatureEnabled, + ), + ), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + stringProvider: StringProvider = FakeStringProvider(), + commandParser: CommandParser = createCommandParser( + featureFlagService = featureFlagService, + appPreferencesStore = appPreferencesStore, + stringProvider = stringProvider, + ), + commandExecutor: CommandExecutor = createCommandExecutor( + stringProvider = stringProvider, + ), + ) = DefaultSlashCommandService( + commandParser = commandParser, + commandExecutor = commandExecutor, + stringProvider = stringProvider, + appPreferencesStore = appPreferencesStore, + featureFlagService = featureFlagService, + ) +} diff --git a/libraries/slashcommands/test/build.gradle.kts b/libraries/slashcommands/test/build.gradle.kts new file mode 100644 index 0000000000..d8a54aa180 --- /dev/null +++ b/libraries/slashcommands/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.slashcommands.test" +} + +dependencies { + implementation(projects.libraries.slashcommands.api) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt b/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt new file mode 100644 index 0000000000..319a8e647c --- /dev/null +++ b/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.slashcommands.test + +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.slashcommands.api.SlashCommand +import io.element.android.libraries.slashcommands.api.SlashCommandService +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeSlashCommandService( + private val getSuggestionsResult: (String, Boolean) -> List = { _, _ -> lambdaError() }, + private val parseResult: (CharSequence, String?, Boolean) -> SlashCommand = { _, _, _ -> lambdaError() }, + private val proceedSendMessageResult: (SlashCommand.SlashCommandSendMessage, Timeline) -> Result = { _, _ -> lambdaError() }, + private val proceedAdminResult: (SlashCommand.SlashCommandAdmin) -> Result = { lambdaError() }, +) : SlashCommandService { + override suspend fun getSuggestions(text: String, isInThread: Boolean): List = simulateLongTask { + getSuggestionsResult(text, isInThread) + } + + override suspend fun parse( + textMessage: CharSequence, + formattedMessage: String?, + isInThreadTimeline: Boolean, + ): SlashCommand = simulateLongTask { + parseResult(textMessage, formattedMessage, isInThreadTimeline) + } + + override suspend fun proceedSendMessage( + slashCommand: SlashCommand.SlashCommandSendMessage, + timeline: Timeline, + ): Result = simulateLongTask { + proceedSendMessageResult(slashCommand, timeline) + } + + override suspend fun proceedAdmin(slashCommand: SlashCommand.SlashCommandAdmin): Result = simulateLongTask { + proceedAdminResult(slashCommand) + } +} diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index a339890201..41e20582e0 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) + implementation(projects.libraries.slashcommands.api) releaseApi(libs.matrix.richtexteditor) releaseApi(libs.matrix.richtexteditor.compose) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt index d91735fb83..f9fd6a2b6d 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion @Immutable sealed interface ResolvedSuggestion { @@ -32,4 +33,8 @@ sealed interface ResolvedSuggestion { size = size, ) } + + data class Command( + val command: SlashCommandSuggestion, + ) : ResolvedSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index ba7e3c50c0..90e5368951 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -61,21 +61,29 @@ class MarkdownTextEditorState( } is ResolvedSuggestion.Member -> { val currentText = SpannableStringBuilder(text.value()) - val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId) + val userId = resolvedSuggestion.roomMember.userId + val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId) currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - this.text.update(currentText, true) - this.selection = IntRange(end + 1, end + 1) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) } is ResolvedSuggestion.Alias -> { val currentText = SpannableStringBuilder(text.value()) - val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias()) + val roomAlias = resolvedSuggestion.roomAlias + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias()) currentText.replace(suggestion.start, suggestion.end, "# ") val end = suggestion.start + 1 currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - this.text.update(currentText, true) - this.selection = IntRange(end + 1, end + 1) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) + } + is ResolvedSuggestion.Command -> { + // Just insert the command text + text.update("${resolvedSuggestion.command.command} ", true) + val length = resolvedSuggestion.command.command.length + 1 + selection = IntRange(length, length) } } } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt index 04b700925e..6e57ce68cb 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionType @@ -42,6 +43,7 @@ class MarkdownTextEditorStateTest { val mentionSpanProvider = aMentionSpanProvider() state.insertSuggestion(suggestion, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() + assertThat(state.text.value().toString()).isEqualTo("Hello @") } @Test @@ -53,6 +55,7 @@ class MarkdownTextEditorStateTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("Hello # ") } @Test @@ -64,6 +67,19 @@ class MarkdownTextEditorStateTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("Hello # ") + } + + @Test + fun `insertSuggestion - command`() { + val state = aMarkdownTextEditorState(initialText = "/rai", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 0, end = 3, type = SuggestionType.Command, text = "/rainbow") + } + val suggestion = aSlashCommandSuggestion() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("/rainbow ") } @Test @@ -74,6 +90,7 @@ class MarkdownTextEditorStateTest { val mentionSpanProvider = aMentionSpanProvider() state.insertSuggestion(mention, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() + assertThat(state.text.value().toString()).isEqualTo("Hello @") } @Test @@ -91,6 +108,7 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId) + assertThat(state.text.value().toString()).isEqualTo("Hello @ ") } @Test @@ -107,15 +125,14 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java) + assertThat(state.text.value().toString()).isEqualTo("Hello @ ") } @Test fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() { val text = "No mentions here" val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) - val markdown = state.getMessageMarkdown(FakePermalinkBuilder()) - assertThat(markdown).isEqualTo(text) } @@ -128,19 +145,17 @@ class MarkdownTextEditorStateTest { ) val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) - val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) - assertThat(markdown).isEqualTo( "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + " and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)" ) + assertThat(state.text.value().toString()).isEqualTo("Hello @ and everyone in @ and a room #room:domain.org") } @Test fun `getMentions - when there are no MentionSpans returns empty list of mentions`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) - assertThat(state.getMentions()).isEmpty() } @@ -148,9 +163,7 @@ class MarkdownTextEditorStateTest { fun `getMentions - when there are MentionSpans returns a list of mentions`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) - val mentions = state.getMentions() - assertThat(mentions).isNotEmpty() assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org") assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) @@ -184,4 +197,14 @@ class MarkdownTextEditorStateTest { roomAvatarUrl = null ) } + + private fun aSlashCommandSuggestion(): ResolvedSuggestion.Command { + return ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/rainbow", + parameters = "param", + description = "Make the text colorful 🌈", + ), + ) + } } diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index e7cc47d7b8..8a5dfa2c4a 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -106,6 +106,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediaupload:impl")) + implementation(project(":libraries:slashcommands:impl")) implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:textcomposer:impl")) implementation(project(":libraries:accountselect:impl")) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png index 6e14838915..986547c8c5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31e730519a460ebc21ebee0d24c429ea22bceb164bd99080fe23b2c1c010577f -size 22488 +oid sha256:d2acf7cae297e8be76765b392dc07a36692a9e597b8f205de31a04dcf8b6bdca +size 37918 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png index b83a34aa0d..d333d2439d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:426d109d550f6298f9375c4a8406210b7d2c52a590678e5c21d4a0ac2864202d -size 22560 +oid sha256:47b4d158df0f83631d099c072b8b3d95a57d539fb9e8d7e6802f6a90a3b78284 +size 37904