Merge pull request #3 from p-num/develop
Some checks failed
Code Quality Checks / Search for forbidden patterns (push) Has been cancelled
Code Quality Checks / Search for invalid screenshot files (push) Has been cancelled
Code Quality Checks / Search for invalid dependencies (push) Has been cancelled
Code Quality Checks / Konsist tests (push) Has been cancelled
Code Quality Checks / Compose tests (push) Has been cancelled
Code Quality Checks / Android lint check (push) Has been cancelled
Code Quality Checks / Detekt checks (push) Has been cancelled
Code Quality Checks / Ktlint checks (push) Has been cancelled
Code Quality Checks / Doc checks (push) Has been cancelled
Code Quality Checks / Check shell scripts (push) Has been cancelled
Code Quality Checks / Project Check Suite (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle (Gplay) (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle Enterprise (push) Has been cancelled
Create release App Bundle and APKs / Create APKs (FDroid) (push) Has been cancelled
Test / Runs unit tests (push) Has been cancelled
Update Gradle Wrapper / update-gradle-wrapper (push) Has been cancelled
Close stale issues that are missing info. / stale (push) Has been cancelled
Build and release nightly application / Build and publish nightly bundle to Firebase (push) Has been cancelled
Nightly reports / Create kover report artifact and upload sonar result. (push) Has been cancelled
Nightly reports / Dependency analysis (push) Has been cancelled
Sync SAS strings / sync-sas-strings (push) Has been cancelled
Sync Localazy / sync-localazy (push) Has been cancelled
Generate GitHub Pages / generate-github-pages (push) Has been cancelled

sync with dev
This commit is contained in:
Letro Bot
2026-04-22 22:25:51 +03:30
111 changed files with 3476 additions and 398 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
# });

View File

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

View File

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

View File

@@ -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 }}

View File

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

View File

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

View File

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

View File

@@ -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
=============================

65
FORK_DIVERGENCE.md Normal file
View File

@@ -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.

7
UPSTREAM_VERSION Normal file
View File

@@ -0,0 +1,7 @@
# Upstream Version
repo: element-hq/element-x-android
upstream_url: <https://github.com/element-hq/element-x-android>
sync_strategy: manual
commit: 4ad495d36c6ccde088848a2ac90c7db62e963421
date: 2026-04-01

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 B

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -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<LoggedInFlowNode.NavTarget>(roomTarget).invoke(currentElements)

View File

@@ -85,6 +85,7 @@ class JoinedRoomLoadedFlowNode(
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
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<String>) {
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)

View File

@@ -16,4 +16,5 @@ class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
}

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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<MessagesNode>(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<ThreadedMessagesNode>(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

View File

@@ -23,6 +23,8 @@ interface MessagesNavigator {
fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun close()
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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<MessageComposerState> {
@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<Unit>>(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<AsyncAction<Unit>>,
) = 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) {

View File

@@ -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<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val resolveAtRoomMentionDisplay: () -> TextDisplay,
val slashCommandAction: AsyncAction<Unit>,
val eventSink: (MessageComposerEvent) -> Unit,
)

View File

@@ -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<ResolvedSuggestion> = persistentListOf(),
slashCommandAction: AsyncAction<Unit> = 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,
)

View File

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

View File

@@ -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 = "<user-id> [reason]",
description = "A slash command with parameters",
)
),
),
onSelectSuggestion = {}
)

View File

@@ -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<RoomAliasSuggestion>,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
isInThread: Boolean,
): List<ResolvedSuggestion> {
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

View File

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

View File

@@ -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)

View File

@@ -24,6 +24,8 @@ class FakeMessagesNavigator(
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> 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()
}

View File

@@ -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<UserId, Unit> {}
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<Unit> { }
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 <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
}

View File

@@ -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<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: 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<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit)
}
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: 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

View File

@@ -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(

View File

@@ -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<IntentionalMention> ->
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention> ->
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val liveTimeline = FakeTimeline(name = "live").apply {

View File

@@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
@Parcelize
data object NotificationTroubleshoot : InitialTarget
@Parcelize
data object DeveloperSettings : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs

View File

@@ -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
}

View File

@@ -192,7 +192,11 @@ class PreferencesFlowNode(
}
override fun onDone() {
backstack.pop()
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
}
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))

View File

@@ -39,6 +39,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)

View File

@@ -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,

View File

@@ -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<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()

View File

@@ -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 {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -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,
),
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): Result<Unit>
suspend fun editMessage(
@@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result<Unit>
suspend fun sendImage(

View File

@@ -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<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
asPlainText = asPlainText,
).use { content ->
runCatchingExceptions<Unit> {
inner.send(content)
}
@@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = 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,

View File

@@ -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<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(intentionalMentions.map())
fun from(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
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())
}
}

View File

@@ -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<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError()
}
@@ -76,8 +79,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions)
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError()
}
@@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
msgType,
)
var sendImageLambda: (

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> 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<IntentionalMention>()))
.with(
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
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<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> 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<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
value(true)
value(true),
value(MsgType.MSG_TYPE_TEXT),
)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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<SlashCommandSuggestion>
/**
* 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<Unit>
/**
* Proceed a SlashCommandAdmin.
*/
suspend fun proceedAdmin(
slashCommand: SlashCommand.SlashCommandAdmin,
): Result<Unit>
}

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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<String>? = 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 = "<message>",
description = R.string.slash_command_description_emote,
),
BAN_USER(
command = "/ban",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_ban_user,
),
UNBAN_USER(
command = "/unban",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_unban_user,
),
IGNORE_USER(
command = "/ignore",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_ignore_user,
),
UNIGNORE_USER(
command = "/unignore",
parameters = "<user-id>",
description = R.string.slash_command_description_unignore_user,
),
SET_USER_POWER_LEVEL(
command = "/op",
parameters = "<user-id> [<power-level>]",
description = R.string.slash_command_description_op_user,
isAllowedInThread = false,
isSupported = false,
),
RESET_USER_POWER_LEVEL(
command = "/deop",
parameters = "<user-id>",
description = R.string.slash_command_description_deop_user,
isAllowedInThread = false,
isSupported = false,
),
ROOM_NAME(
command = "/roomname",
parameters = "<name>",
description = R.string.slash_command_description_room_name,
isAllowedInThread = false,
),
INVITE(
command = "/invite",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_invite_user,
),
JOIN_ROOM(
command = "/join",
aliases = listOf("/j", "/goto"),
parameters = "<room-address> [reason]",
description = R.string.slash_command_description_join_room,
isAllowedInThread = false,
isSupported = false,
),
TOPIC(
command = "/topic",
parameters = "<topic>",
description = R.string.slash_command_description_topic,
isAllowedInThread = false,
),
REMOVE_USER(
command = "/remove",
aliases = listOf("/kick"),
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_remove_user,
),
CHANGE_DISPLAY_NAME(
command = "/nick",
parameters = "<display-name>",
description = R.string.slash_command_description_nick,
),
CHANGE_DISPLAY_NAME_FOR_ROOM(
command = "/myroomnick",
aliases = listOf("/roomnick"),
parameters = "<display-name>",
description = R.string.slash_command_description_nick_for_room,
isAllowedInThread = false,
isSupported = false,
),
ROOM_AVATAR(
command = "/roomavatar",
parameters = "<mxc_url>",
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 = "<mxc_url>",
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 = "<message>",
description = R.string.slash_command_description_rainbow,
),
RAINBOW_EMOTE(
command = "/rainbowme",
parameters = "<message>",
description = R.string.slash_command_description_rainbow_emote,
),
DEVTOOLS(
command = "/devtools",
description = R.string.slash_command_description_devtools,
isDevCommand = true,
),
SPOILER(
command = "/spoiler",
parameters = "<message>",
description = R.string.slash_command_description_spoiler,
),
SHRUG(
command = "/shrug",
parameters = "<message>",
description = R.string.slash_command_description_shrug,
),
LENNY(
command = "/lenny",
parameters = "<message>",
description = R.string.slash_command_description_lenny,
),
PLAIN(
command = "/plain",
parameters = "<message>",
description = R.string.slash_command_description_plain,
),
WHOIS(
command = "/whois",
parameters = "<user-id>",
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 = "<message>",
description = R.string.slash_command_confetti,
isAllowedInThread = false,
isSupported = false,
),
SNOWFALL(
command = "/snowfall",
parameters = "<message>",
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 = "<message>",
description = R.string.slash_command_description_table_flip,
),
UNFLIP(
command = "/unflip",
parameters = "<message>",
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) }
}

View File

@@ -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<Unit> {
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<Unit> {
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<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result<Unit> {
return matrixClient.unignoreUser(slashCommand.userId)
}
private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result<Unit> {
return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason)
}
private fun setUserPowerLevel(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result<Unit> {
val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})"
val formattedText = "<span data-mx-spoiler>${slashCommand.message}</span>"
return timeline.sendMessage(
body = text,
htmlBody = formattedText,
intentionalMentions = emptyList(),
)
}
private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result<Unit> {
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<Unit> {
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<Unit> {
return timeline.sendMessage(
body = slashCommand.message.toString(),
htmlBody = null,
intentionalMentions = emptyList(),
asPlainText = true,
)
}
private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result<Unit> {
val message = slashCommand.message.toString()
return timeline.sendMessage(
body = message,
htmlBody = null,
msgType = MsgType.MSG_TYPE_EMOTE,
intentionalMentions = emptyList(),
)
}
private fun sendChatEffect(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result<Unit> {
return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason)
}
private suspend fun leaveRoom(
room: JoinedRoom,
): Result<Unit> {
return room.leave()
}
private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result<Unit> {
return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList())
.map {}
}
private suspend fun invite(slashCommand: SlashCommand.Invite): Result<Unit> {
return joinedRoom.inviteUserById(slashCommand.userId)
}
private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result<Unit> {
return matrixClient.ignoreUser(slashCommand.userId)
}
private fun discardSession(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result<Unit> {
return joinedRoom.setTopic(slashCommand.topic)
}
private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result<Unit> {
return joinedRoom.setName(slashCommand.name)
}
private fun changeRoomAvatar(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private fun changeDisplayNameForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result<Unit> {
return matrixClient.setDisplayName(slashCommand.displayName)
}
private fun changeAvatarForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result<Unit> {
return joinedRoom.banUser(slashCommand.userId, slashCommand.reason)
}
private suspend fun sendPrefixedMessage(
prefix: MessagePrefix,
message: CharSequence,
timeline: Timeline,
): Result<Unit> {
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 -> "( ͡° ͜ʖ ͡°)"
}

View File

@@ -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<String>): UserId? {
val str = messageParts.getOrNull(1) ?: return null
return when {
MatrixPatterns.isUserId(str) -> str
str == "<a" -> {
// Rich text editor mode
messageParts.getOrNull(2)?.let { html ->
// html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org</a>"
val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)</a>".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<List<String>, 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<Command> 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>): String? {
val partsSize = messageParts.sumOf { it.length }
val gapsNumber = messageParts.size - 1
return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() }
}
}

View File

@@ -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<SlashCommandSuggestion> {
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<Unit> {
return commandExecutor.proceedSendMessage(
slashCommand = slashCommand,
timeline = timeline,
)
}
override suspend fun proceedAdmin(
slashCommand: SlashCommand.SlashCommandAdmin,
): Result<Unit> {
return commandExecutor.proceedAdmin(
slashCommand = slashCommand,
)
}
}

View File

@@ -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()
"<font color=\"$dashColor\">$letter</font>"
}
}
.joinToString(separator = "")
}
private fun generateAB(hue: Double, chroma: Float): Pair<Double, Double> {
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<CharSequence> {
val result = mutableListOf<CharSequence>()
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
}

View File

@@ -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')
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<resources>
<string name="slash_command_error">Command error</string>
<string name="slash_command_unrecognized">Unrecognized command: %1$s</string>
<string name="slash_command_parameters_error">The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s</string>
<string name="slash_command_not_supported_in_threads">The command \"%1$s\" is recognized but not supported in threads.</string>
<string name="slash_command_description_emote">Displays action</string>
<string name="slash_command_description_crash_application">Crash the application.</string>
<string name="slash_command_description_ban_user">Bans user with given id</string>
<string name="slash_command_description_unban_user">Unbans user with given id</string>
<string name="slash_command_description_ignore_user">Ignores a user, hiding their messages from you</string>
<string name="slash_command_description_unignore_user">Stops ignoring a user, showing their messages going forward</string>
<string name="slash_command_description_op_user">Define the power level of a user</string>
<string name="slash_command_description_deop_user">Deops user with given id</string>
<string name="slash_command_description_room_name">Sets the room name</string>
<string name="slash_command_description_rainbow">Sends the given message colored as a rainbow</string>
<string name="slash_command_description_rainbow_emote">Sends the given emote colored as a rainbow</string>
<string name="slash_command_description_invite_user">Invites user with given id to current room</string>
<string name="slash_command_description_join_room">Joins room with given address</string>
<string name="slash_command_description_spoiler">Sends the given message as a spoiler</string>
<string name="slash_command_description_topic">Set the room topic</string>
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
<string name="slash_command_description_nick">Changes your display nickname</string>
<string name="slash_command_confetti">Sends the given message with confetti</string>
<string name="slash_command_snow">Sends the given message with snowfall</string>
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>
<string name="slash_command_description_nick_for_room">Changes your display nickname in the current room only</string>
<string name="slash_command_description_room_avatar">Changes the avatar of the current room</string>
<string name="slash_command_description_avatar_for_room">Changes your avatar in this current room only</string>
<string name="slash_command_description_devtools">Open the developer tools screen</string>
<string name="slash_command_description_whois">Displays information about a user</string>
<string name="slash_command_description_shrug">Prepends ¯\\_(ツ)_/¯ to a plain-text message</string>
<string name="slash_command_description_lenny">Prepends ( ͡° ͜ʖ ͡°) to a plain-text message</string>
<string name="slash_command_description_table_flip">Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message</string>
<string name="slash_command_description_unflip">Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message</string>
<string name="slash_command_description_discard_session">Forces the current outbound group session in an encrypted room to be discarded</string>
<string name="slash_command_description_discard_session_not_handled">Only supported in encrypted rooms</string>
<string name="slash_command_description_leave_room">Leave the current room</string>
<string name="slash_command_description_upgrade_room">Upgrades a room to a new version</string>
<string name="common_spoiler">Spoiler</string>
</resources>

View File

@@ -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("<font") || capturedHtml!!.contains("<span")).isTrue()
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_TEXT)
}
@Test
fun `send rainbow emote 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.SendRainbowEmote("a nice rainbow"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("a nice rainbow")
assertThat(capturedHtml).isNotNull()
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
}
@Test
fun `change display name invokes the method of the matrix client`() = runTest {
val matrixClient = FakeMatrixClient()
val sut = createCommandExecutor(matrixClient = matrixClient)
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayName("new name"))
assertThat(res.isSuccess).isTrue()
assertThat(matrixClient.setDisplayNameCalled).isTrue()
}
@Test
fun `change room avatar is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeRoomAvatar(AN_AVATAR_URL))
assertThat(res.isFailure).isTrue()
}
@Test
fun `change avatar for room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeAvatarForRoom(AN_AVATAR_URL))
assertThat(res.isFailure).isTrue()
}
@Test
fun `change display name for room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayNameForRoom(A_USER_NAME))
assertThat(res.isFailure).isTrue()
}
@Test
fun `upgrade room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.UpgradeRoom("1"))
assertThat(res.isFailure).isTrue()
}
@Test
fun `set user power level is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
assertThat(res.isFailure).isTrue()
}
@Test
fun `discard session is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.DiscardSession)
assertThat(res.isFailure).isTrue()
}
@Test
fun `send spoiler sets formatted and body includes spoiler label`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
var capturedHtml: String? = null
timeline.sendMessageLambda = { body, htmlBody, _, _, _ ->
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("<span data-mx-spoiler>secret</span>")
}
@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,
)

Some files were not shown because too many files have changed in this diff Show More