Merge branch 'develop' into feature-oled-black

This commit is contained in:
Timur Gilfanov 2026-04-05 12:06:20 +04:00 committed by GitHub
commit e3ae51b577
291 changed files with 4973 additions and 1595 deletions

View file

@ -56,7 +56,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs

View file

@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK

View file

@ -12,6 +12,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
permissions:
contents: write
steps: steps:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4 uses: nschloe/action-cached-lfs-checkout@1c185ad576953eab13e35ffe1bffef437d97e9d2 # v1.2.4
@ -21,7 +23,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -50,7 +50,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK

View file

@ -43,7 +43,7 @@ jobs:
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: false cache-read-only: false
@ -85,7 +85,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -74,7 +74,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@ -113,7 +113,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@ -154,7 +154,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests - name: Run compose tests
@ -188,7 +188,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@ -233,7 +233,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@ -274,7 +274,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check

View file

@ -59,7 +59,7 @@ jobs:
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -43,7 +43,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - 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 - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -87,7 +87,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - 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 - name: Create Enterprise app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -131,7 +131,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -19,6 +19,9 @@ adb install -r $1
echo "Starting the screen recording..." echo "Starting the screen recording..."
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/ adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/
adb shell "chmod +x /data/local/tmp/local-recording.sh" adb shell "chmod +x /data/local/tmp/local-recording.sh"
mkdir -p ~/.maestro/tests
# Start logcat in the background and save the output to a file, use `org.matrix.rust.sdk` tag since the SDK handles the logging
adb logcat 'org.matrix.rust.sdk:D *:S' > ~/.maestro/tests/logcat.txt &
adb shell "/data/local/tmp/local-recording.sh & echo \$! > /data/local/tmp/screenrecord_pid.txt" & adb shell "/data/local/tmp/local-recording.sh & echo \$! > /data/local/tmp/screenrecord_pid.txt" &
set +e set +e
~/.maestro/bin/maestro test .maestro/allTests.yaml ~/.maestro/bin/maestro test .maestro/allTests.yaml

View file

@ -50,7 +50,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures

View file

@ -22,7 +22,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -68,7 +68,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -2,14 +2,14 @@ appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: - tapOn:
id: "home_screen-settings" id: "home_screen-settings"
- tapOn: "Sign out" - tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
- tapOn: "Sign out" - tapOn: "Remove this device"
# Ensure cancel cancels # Ensure cancel cancels
- tapOn: - tapOn:
id: "dialog-negative" id: "dialog-negative"
- tapOn: "Sign out" - tapOn: "Remove this device"
- tapOn: - tapOn:
id: "dialog-positive" id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml - runFlow: ../assertions/assertInitDisplayed.yaml

View file

@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Confirm your identity" visible: "Confirm your digital identity"
timeout: 60000 timeout: 60000

View file

@ -1,7 +1,7 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
# Purpose: Test the creation and deletion of a DM room. # Purpose: Test the creation and deletion of a DM room.
- tapOn: "Create a new conversation or room" - tapOn: "Create room"
- tapOn: "Search for someone" - tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE1_MXID} - inputText: ${MAESTRO_INVITEE1_MXID}
- tapOn: - tapOn:

View file

@ -1,7 +1,7 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
# Purpose: Test the creation and deletion of a room # Purpose: Test the creation and deletion of a room
- tapOn: "Create a new conversation or room" - tapOn: "Create room"
- tapOn: "New room" - tapOn: "New room"
- tapOn: "Add name…" - tapOn: "Add name…"
- inputText: "aRoomName" - inputText: "aRoomName"

View file

@ -2,6 +2,6 @@ appId: ${MAESTRO_APP_ID}
--- ---
- takeScreenshot: build/maestro/520-Timeline - takeScreenshot: build/maestro/520-Timeline
- tapOn: "Add attachment" - tapOn: "Add attachment"
- tapOn: "Location" - tapOn: "Share location"
- tapOn: "Share my location" - tapOn: "Share selected location"
- takeScreenshot: build/maestro/521-Timeline - takeScreenshot: build/maestro/521-Timeline

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 Changes in Element X v26.03.4
============================= =============================

View file

@ -424,6 +424,10 @@ class LoggedInFlowNode(
override fun navigateToGlobalNotificationSettings() { override fun navigateToGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
} }
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.DeveloperSettings))
}
} }
val inputs = RoomFlowNode.Inputs( val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias, roomIdOrAlias = navTarget.roomIdOrAlias,
@ -747,11 +751,11 @@ private class AttachRoomOperation(
} }
} + // Always create a new element, otherwise we wouldn't be navigating to the target event id or child node } + // Always create a new element, otherwise we wouldn't be navigating to the target event id or child node
BackStackElement( BackStackElement(
key = NavKey(roomTarget), key = NavKey(roomTarget),
fromState = CREATED, fromState = CREATED,
targetState = ACTIVE, targetState = ACTIVE,
operation = this operation = this
) )
} else { } else {
// Otherwise, just push the new node to the end of the backstack // Otherwise, just push the new node to the end of the backstack
Push<LoggedInFlowNode.NavTarget>(roomTarget).invoke(currentElements) Push<LoggedInFlowNode.NavTarget>(roomTarget).invoke(currentElements)

View file

@ -85,6 +85,7 @@ class JoinedRoomLoadedFlowNode(
fun navigateToRoom(roomId: RoomId, serverNames: List<String>) fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings() fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
} }
data class Inputs( data class Inputs(
@ -145,6 +146,10 @@ class JoinedRoomLoadedFlowNode(
callback.navigateToGlobalNotificationSettings() callback.navigateToGlobalNotificationSettings()
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
callback.navigateToRoom(roomId, serverNames) callback.navigateToRoom(roomId, serverNames)
} }
@ -252,6 +257,10 @@ class JoinedRoomLoadedFlowNode(
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList()) callback.navigateToRoom(roomId, emptyList())
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
} }
val params = MessagesEntryPoint.Params( val params = MessagesEntryPoint.Params(
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId) 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 navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
} }

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes for crashes from the SDK and notifications and UI improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string>
<string name="screen_identity_confirmation_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string> <string name="screen_identity_confirmation_subtitle">"Vælg, hvordan du vil verificere dig for at konfigurere sikre beskeder."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</string> <string name="screen_identity_confirmation_title">"Bekræft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string> <string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string> <string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string> <string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>

View file

@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string> <string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string>
<string name="banner_new_sound_message">"Dit notifikationsping er blevet opdateret tydeligere, hurtigere og mindre forstyrrende."</string> <string name="banner_new_sound_message">"Dit notifikationsping er blevet opdateret tydeligere, hurtigere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har opdateret dine lyde"</string> <string name="banner_new_sound_title">"Vi har opdateret dine lyde"</string>
<string name="banner_set_up_recovery_content">"Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string> <string name="banner_set_up_recovery_content">"Dine chats sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="banner_set_up_recovery_submit">"Opsæt gendannelse"</string> <string name="banner_set_up_recovery_submit">"Hent gendannelsesnøgle"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</string> <string name="banner_set_up_recovery_title">"Sikkerhedskopier dine samtaler"</string>
<string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string> <string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Vælg, hvor længe du vil dele din aktuelle position."</string>
</resources>

View file

@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.location.impl.share package io.element.android.features.location.impl.share
import app.cash.molecule.RecompositionMode 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule

View file

@ -23,7 +23,7 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Indtast venligst den samme PIN-kode to gange"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Indtast venligst den samme PIN-kode to gange"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koderne stemmer ikke overens"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koderne stemmer ikke overens"</string>
<string name="screen_app_lock_signout_alert_message">"Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte."</string> <string name="screen_app_lock_signout_alert_message">"Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte."</string>
<string name="screen_app_lock_signout_alert_title">"Du bliver logget ud"</string> <string name="screen_app_lock_signout_alert_title">"Denne enhed bliver fjernet"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d forsøg på at låse op"</item> <item quantity="one">"Du har %1$d forsøg på at låse op"</item>
<item quantity="other">"Du har %1$d forsøg på at låse op"</item> <item quantity="other">"Du har %1$d forsøg på at låse op"</item>
@ -34,5 +34,5 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Brug biometri"</string> <string name="screen_app_lock_use_biometric_android">"Brug biometri"</string>
<string name="screen_app_lock_use_pin_android">"Brug PIN-kode"</string> <string name="screen_app_lock_use_pin_android">"Brug PIN-kode"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string> <string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources> </resources>

View file

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at du vil logge ud?"</string> <string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at ønsker at fjerne denne enhed?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Log ud"</string> <string name="screen_signout_confirmation_dialog_submit">"Fjern denne enhed"</string>
<string name="screen_signout_confirmation_dialog_title">"Log ud"</string> <string name="screen_signout_confirmation_dialog_title">"Fjern denne enhed"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string> <string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger ud nu, mister du adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_key_backup_disabled_title">"Du har slået sikkerhedskopiering fra"</string> <string name="screen_signout_key_backup_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du logger ud."</string> <string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_offline_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string> <string name="screen_signout_key_backup_offline_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Vent på, at dette er fuldført, før du logger ud."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Vent venligst, indtil dette er færdigt, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_ongoing_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string> <string name="screen_signout_key_backup_ongoing_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_preference_item">"Log ud"</string> <string name="screen_signout_preference_item">"Fjern denne enhed"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, mister du adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_recovery_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_recovery_disabled_title">"Gendannelse er ikke konfigureret"</string> <string name="screen_signout_recovery_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, kan du miste adgangen til dine krypterede meddelelser."</string> <string name="screen_signout_save_recovery_key_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_save_recovery_key_title">"Sørg for, at du har adgang til din gendannelsesnøgle, før du fjerner denne enhed."</string>
</resources> </resources>

View file

@ -38,6 +38,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
fun navigateToRoom(roomId: RoomId) fun navigateToRoom(roomId: RoomId)
fun navigateToDeveloperSettings()
} }
data class Params(val initialTarget: InitialTarget) : NodeInputs data class Params(val initialTarget: InitialTarget) : NodeInputs

View file

@ -53,6 +53,7 @@ dependencies {
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.roomselect.api) implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api) implementation(projects.libraries.audio.api)
implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.voicerecorder.api)
@ -104,4 +105,5 @@ dependencies {
testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.recentemojis.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?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
} }
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -502,6 +506,10 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
} }
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback)) createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
} }
@ -567,7 +575,7 @@ class MessagesFlowNode(
assetType = event.content.assetType, assetType = event.content.assetType,
) )
NavTarget.LocationViewer( NavTarget.LocationViewer(
mode = mode mode = mode
).takeIf { locationService.isServiceAvailable() } ).takeIf { locationService.isServiceAvailable() }
} }
else -> null else -> null

View file

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

View file

@ -105,7 +105,7 @@ class MessagesNode(
private val timelineController = TimelineController(room, room.liveTimeline) private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this), composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create( actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default, postProcessor = TimelineItemActionPostProcessor.Default,
@ -130,6 +130,7 @@ class MessagesNode(
fun navigateToRoomDetails() fun navigateToRoomDetails()
fun navigateToPinnedMessagesList() fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList() fun navigateToKnockRequestsList()
fun navigateToDeveloperSettings()
} }
override fun onBuilt() { override fun onBuilt() {
@ -222,10 +223,18 @@ class MessagesNode(
} }
} }
override fun navigateToMember(userId: UserId) {
callback.navigateToRoomMemberDetails(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callback.navigateToThread(threadRootId, focusedEventId) callback.navigateToThread(threadRootId, focusedEventId)
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
private fun displaySameRoomToast() { private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android) context.toast(CommonStrings.screen_room_permalink_same_room_android)
} }

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@ -464,6 +465,9 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
) )
val density = LocalDensity.current
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
@ -479,11 +483,13 @@ private fun MessagesViewContent(
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = pinnedBannerHeightDp,
) )
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility( AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically(), exit = shrinkVertically(),
) { ) {

View file

@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
data object SaveDraft : 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf 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.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator 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
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.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService 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.RoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor 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.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper 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.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes 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.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService 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.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState 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( class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
@Assisted private val timelineController: TimelineController, @Assisted private val timelineController: TimelineController,
@Assisted private val isInThread: Boolean,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom, private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
@ -125,10 +132,15 @@ class MessageComposerPresenter(
private val suggestionsProcessor: SuggestionsProcessor, private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService, private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { 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()) private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
@ -218,6 +230,8 @@ class MessageComposerPresenter(
} }
) )
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val draft = draftService.loadDraft( val draft = draftService.loadDraft(
roomId = room.roomId, roomId = room.roomId,
@ -246,12 +260,13 @@ class MessageComposerPresenter(
sessionCoroutineScope.sendMessage( sessionCoroutineScope.sendMessage(
markdownTextEditorState = markdownTextEditorState, markdownTextEditorState = markdownTextEditorState,
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
slashCommandAction = slashCommandAction,
) )
} }
is MessageComposerEvent.SendUri -> { is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment( sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media( attachment = Media(
localMedia = localMediaFactory.createFromUri( localMedia = localMediaFactory.createFromUri(
uri = event.uri, uri = event.uri,
mimeType = null, mimeType = null,
@ -340,6 +355,9 @@ class MessageComposerPresenter(
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
is ResolvedSuggestion.Command -> {
richTextEditorState.replaceSuggestion(suggestion.command.command)
}
} }
} else if (markdownTextEditorState.currentSuggestion != null) { } else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertSuggestion( markdownTextEditorState.insertSuggestion(
@ -354,6 +372,9 @@ class MessageComposerPresenter(
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
sessionCoroutineScope.updateDraft(draft, isVolatile = false) sessionCoroutineScope.updateDraft(draft, isVolatile = false)
} }
MessageComposerEvent.ClearSlashError -> {
slashCommandAction.value = AsyncAction.Uninitialized
}
} }
} }
@ -385,6 +406,7 @@ class MessageComposerPresenter(
suggestions = suggestions.toImmutableList(), suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
slashCommandAction = slashCommandAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@ -422,6 +444,7 @@ class MessageComposerPresenter(
roomAliasSuggestions = roomAliasSuggestions, roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId, currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention, canSendRoomMention = ::canSendRoomMention,
isInThread = isInThread,
) )
suggestions.clear() suggestions.clear()
suggestions.addAll(result) suggestions.addAll(result)
@ -433,9 +456,69 @@ class MessageComposerPresenter(
private fun CoroutineScope.sendMessage( private fun CoroutineScope.sendMessage(
markdownTextEditorState: MarkdownTextEditorState, markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState, richTextEditorState: RichTextEditorState,
slashCommandAction: MutableState<AsyncAction<Unit>>,
) = launch { ) = launch {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true) val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
val capturedMode = messageComposerContext.composerMode 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 // Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {

View file

@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Stable 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.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
@ -26,5 +27,6 @@ data class MessageComposerState(
val suggestions: ImmutableList<ResolvedSuggestion>, val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay, val resolveMentionDisplay: (String, String) -> TextDisplay,
val resolveAtRoomMentionDisplay: () -> TextDisplay, val resolveAtRoomMentionDisplay: () -> TextDisplay,
val slashCommandAction: AsyncAction<Unit>,
val eventSink: (MessageComposerEvent) -> Unit, val eventSink: (MessageComposerEvent) -> Unit,
) )

View file

@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
@ -32,6 +33,7 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false, showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true, canShareLocation: Boolean = true,
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(), suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (MessageComposerEvent) -> Unit = {}, eventSink: (MessageComposerEvent) -> Unit = {},
) = MessageComposerState( ) = MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
@ -43,5 +45,6 @@ fun aMessageComposerState(
suggestions = suggestions, suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain }, resolveAtRoomMentionDisplay = { TextDisplay.Plain },
slashCommandAction = slashCommandAction,
eventSink = eventSink, 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.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer
@ -115,6 +116,12 @@ internal fun MessageComposerView(
onTyping = ::onTyping, onTyping = ::onTyping,
onSelectRichContent = ::sendUri, onSelectRichContent = ::sendUri,
) )
AsyncActionView(
async = state.slashCommandAction,
onSuccess = {},
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
)
} }
@PreviewsDayNight @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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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
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.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.ui.model.getAvatarData 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 io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -63,6 +65,7 @@ fun SuggestionsPickerView(
is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomId.value is ResolvedSuggestion.Alias -> suggestion.roomId.value
is ResolvedSuggestion.Command -> suggestion.command.command
} }
} }
) { ) {
@ -91,54 +94,81 @@ private fun SuggestionItemView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier.clickable { onSelectSuggestion(suggestion) }, modifier = modifier
horizontalArrangement = Arrangement.spacedBy(16.dp), .clickable { onSelectSuggestion(suggestion) }
.padding(horizontal = 16.dp),
) { ) {
val avatarSize = AvatarSize.Suggestion val avatarSize = AvatarSize.Suggestion
val avatarData = when (suggestion) { val avatarData = when (suggestion) {
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
is ResolvedSuggestion.Command -> null
} }
val avatarType = when (suggestion) { val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> AvatarType.Room() is ResolvedSuggestion.Alias -> Room()
ResolvedSuggestion.AtRoom, ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member -> AvatarType.User is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null
} }
val title = when (suggestion) { val title = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Alias -> suggestion.roomName 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) { val subtitle = when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomAlias.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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp) .padding(top = 8.dp, bottom = 8.dp)
.align(Alignment.CenterVertically), .align(Alignment.CenterVertically),
) { ) {
title?.let { Row(
Text( verticalAlignment = Alignment.CenterVertically,
text = it, horizontalArrangement = Arrangement.spacedBy(4.dp)
style = ElementTheme.typography.fontBodyLgRegular, ) {
maxLines = 1, title?.let {
overflow = TextOverflow.Ellipsis, 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(
text = subtitle, text = subtitle,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
@ -174,7 +204,21 @@ internal fun SuggestionsPickerViewPreview() {
roomId = RoomId("!room:matrix.org"), roomId = RoomId("!room:matrix.org"),
roomName = "My room", roomName = "My room",
roomAvatarUrl = null, 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 = {} 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.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers 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.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType 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. * This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
*/ */
@Inject @Inject
class SuggestionsProcessor { class SuggestionsProcessor(
private val slashCommandService: SlashCommandService,
) {
/** /**
* Process the suggestion. * Process the suggestion.
* @param suggestion The current suggestion input * @param suggestion The current suggestion input
@ -31,6 +34,7 @@ class SuggestionsProcessor {
* @param roomAliasSuggestions The available room alias suggestions * @param roomAliasSuggestions The available room alias suggestions
* @param currentUserId The current user id * @param currentUserId The current user id
* @param canSendRoomMention Should return true if the current user can send room mentions * @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 * @return The list of suggestions to display
*/ */
suspend fun process( suspend fun process(
@ -39,6 +43,7 @@ class SuggestionsProcessor {
roomAliasSuggestions: List<RoomAliasSuggestion>, roomAliasSuggestions: List<RoomAliasSuggestion>,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean, canSendRoomMention: suspend () -> Boolean,
isInThread: Boolean,
): List<ResolvedSuggestion> { ): List<ResolvedSuggestion> {
suggestion ?: return emptyList() suggestion ?: return emptyList()
return when (suggestion.type) { 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, SuggestionType.Emoji,
is SuggestionType.Custom -> { is SuggestionType.Custom -> {
// Clear suggestions // Clear suggestions

View file

@ -112,7 +112,7 @@ class ThreadedMessagesNode(
this.timelineController = timelineController this.timelineController = timelineController
return presenterFactory.create( return presenterFactory.create(
navigator = this, navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this), composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline // TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create( actionListPresenter = actionListPresenterFactory.create(
@ -136,6 +136,7 @@ class ThreadedMessagesNode(
fun navigateToEditPoll(eventId: EventId) fun navigateToEditPoll(eventId: EventId)
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
} }
override fun onBuilt() { override fun onBuilt() {
@ -233,10 +234,18 @@ class ThreadedMessagesNode(
callback.handlePermalinkClick(permalinkData) callback.handlePermalinkClick(permalinkData)
} }
override fun navigateToMember(userId: UserId) {
callback.navigateToRoomMemberDetails(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callback.navigateToThread(threadRootId, focusedEventId) callback.navigateToThread(threadRootId, focusedEventId)
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun close() = navigateUp() override fun close() = navigateUp()
@Composable @Composable

View file

@ -149,6 +149,9 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) { val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
} }
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
when (event) { when (event) {
@ -315,6 +318,7 @@ class TimelinePresenter(
messageShieldDialogData = messageShieldDialogData.value, messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -34,6 +34,7 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?, val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val eventSink: (TimelineEvent) -> Unit, val eventSink: (TimelineEvent) -> Unit,
) { ) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event

View file

@ -56,6 +56,7 @@ fun aTimelineState(
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false, displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -75,6 +76,7 @@ fun aTimelineState(
messageShieldDialogData = messageShield?.let { MessageShieldData(it) }, messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = eventSink, eventSink = eventSink,
) )
} }

View file

@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@ -105,6 +107,7 @@ fun TimelineView(
lazyListState: LazyListState = rememberLazyListState(), lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) { ) {
fun clearFocusRequestState() { fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState) state.eventSink(TimelineEvent.ClearFocusRequestState)
@ -210,6 +213,15 @@ fun TimelineView(
onJumpToLive = ::onJumpToLive, onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender, onFocusEventRender = ::onFocusEventRender,
) )
if (state.displayFloatingDateBadge && useReverseLayout) {
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
} }
} }

View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 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.features.messages.impl.timeline.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun BoxScope.FloatingDateBadgeOverlay(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
isLive: Boolean,
topOffset: Dp = 0.dp,
) {
// This needs to be a state to trigger a `derivedState` recalculation
val updatedTimelineItems by rememberUpdatedState(timelineItems)
// Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list
val lastVisibleItemWithTimestamp by remember {
derivedStateOf {
var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null
while (index >= 0) {
when (val item = updatedTimelineItems.getOrNull(index)) {
is TimelineItem.Event -> return@derivedStateOf item
is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item
is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull()
null -> Unit
}
index--
}
null
}
}
// Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed
var formattedDate: String? by remember { mutableStateOf(null) }
// Update the formatted date when we have a new non-null timestamp
LaunchedEffect(lastVisibleItemWithTimestamp) {
lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it }
}
val isAtBottom by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
var isBadgeVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { lazyListState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isBadgeVisible = true
} else {
delay(2000.milliseconds)
isBadgeVisible = false
}
}
}
val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null
AnimatedVisibility(
visible = showBadge,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp + topOffset),
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
) {
formattedDate?.let { dateText ->
FloatingDateBadge(
modifier = Modifier.padding(8.dp),
dateText = dateText,
)
}
}
}
@Composable
internal fun FloatingDateBadge(
dateText: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = ElementTheme.colors.floatingDateBadgeBackground,
shadowElevation = 4.dp,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = dateText,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun FloatingDateBadgePreview() = ElementPreview {
Box(modifier = Modifier.padding(16.dp)) {
FloatingDateBadge(dateText = "March 9, 2026")
}
}

View file

@ -66,6 +66,11 @@ class TimelineItemEventFactory(
timestamp = currentTimelineItem.event.timestamp, timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly, mode = DateFormatterMode.TimeOnly,
) )
val sentDate = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
val senderAvatarData = AvatarData( val senderAvatarData = AvatarData(
id = currentSender.value, id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender), name = senderProfile.getDisambiguatedDisplayName(currentSender),
@ -108,6 +113,7 @@ class TimelineItemEventFactory(
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp, sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime, sentTime = sentTime,
sentDate = sentDate,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -59,6 +60,12 @@ sealed interface TimelineItem {
is GroupedEvents -> "groupedEvent" is GroupedEvents -> "groupedEvent"
} }
fun formattedDate(): String? = when (this) {
is Event -> sentDate.takeIf { it.isNotEmpty() }
is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() }
is GroupedEvents -> null
}
data class Virtual( data class Virtual(
val id: UniqueId, val id: UniqueId,
val model: TimelineItemVirtualModel val model: TimelineItemVirtualModel
@ -75,6 +82,7 @@ sealed interface TimelineItem {
val content: TimelineItemEventContent, val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L, val sentTimeMillis: Long = 0L,
val sentTime: String = "", val sentTime: String = "",
val sentDate: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean, val canBeRepliedTo: Boolean,

View file

@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest {
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
override fun navigateToRoom(roomId: RoomId) = lambdaError() override fun navigateToRoom(roomId: RoomId) = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
} }
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
val params = MessagesEntryPoint.Params(initialTarget) val params = MessagesEntryPoint.Params(initialTarget)

View file

@ -24,6 +24,8 @@ class FakeMessagesNavigator(
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: 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 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 onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val closeLambda: () -> Unit = { lambdaError() }, private val closeLambda: () -> Unit = { lambdaError() },
) : MessagesNavigator { ) : MessagesNavigator {
@ -51,10 +53,18 @@ class FakeMessagesNavigator(
onNavigateToRoomLambda(roomId, eventId, serverNames) onNavigateToRoomLambda(roomId, eventId, serverNames)
} }
override fun navigateToMember(userId: UserId) {
navigateToMemberLambda(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
onOpenThreadLambda(threadRootId, focusedEventId) onOpenThreadLambda(threadRootId, focusedEventId)
} }
override fun navigateToDeveloperSettings() {
navigateToDeveloperSettingsLambda()
}
override fun close() { override fun close() {
closeLambda() 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.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper 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.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId 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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft 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.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.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId 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.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService 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.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@ -144,6 +149,7 @@ class MessageComposerPresenterTest {
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue() assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized)
} }
} }
@ -374,10 +380,13 @@ class MessageComposerPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) } sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
}, },
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
), ),
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present() val state = presenter.present()
@ -409,10 +418,13 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled = false, isRichTextEditorEnabled = false,
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) } sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
}, },
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
), ),
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present() val state = presenter.present()
@ -602,7 +614,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - reply message`() = runTest { 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) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
@ -633,7 +645,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda) assert(replyMessageLambda)
.isCalledOnce() .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( assertThat(analyticsService.capturedEvents).containsExactly(
Composer( Composer(
@ -967,7 +979,12 @@ class MessageComposerPresenterTest {
) )
givenRoomInfo(aRoomInfo(isDirect = false)) givenRoomInfo(aRoomInfo(isDirect = false))
} }
val presenter = createPresenter(room) val presenter = createPresenter(
room = room,
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ -> emptyList() },
),
)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - send messages with intentional mentions`() = runTest { 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) Result.success(Unit)
} }
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> -> val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> -> val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest {
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(room = room) val presenter = createPresenter(
room = room,
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest {
advanceUntilIdle() advanceUntilIdle()
sendMessageResult.assertions().isCalledOnce() 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 // Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode())) initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda) assert(replyMessageLambda)
.isCalledOnce() .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 // Check intentional mentions on edit message
skipItems(1) skipItems(1)
@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true, isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
isInThread: Boolean = false,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
sessionCoroutineScope = this, sessionCoroutineScope = this,
isInThread = isInThread,
room = room, room = room,
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest {
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(), suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService, notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled 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.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary 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.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
@ -27,10 +29,13 @@ import org.junit.Test
class SuggestionsProcessorTest { class SuggestionsProcessorTest {
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text) private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, 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 aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
private val suggestionsProcessor = SuggestionsProcessor() private val suggestionsProcessor = SuggestionsProcessor(
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ -> emptyList() },
),
)
@Test @Test
fun `processing null suggestion will return empty suggestion`() = runTest { fun `processing null suggestion will return empty suggestion`() = runTest {
@ -40,18 +45,59 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@Test @Test
fun `processing Command will return empty suggestion`() = runTest { fun `processing Command will return suggestions from the slash service`() = runTest {
val result = suggestionsProcessor.process( val suggestionsProcessorWithCommand = SuggestionsProcessor(
suggestion = aCommandSuggestion, 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())), roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, 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() assertThat(result).isEmpty()
} }
@ -64,6 +110,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -76,6 +123,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -88,6 +136,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -100,6 +149,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -120,6 +170,7 @@ class SuggestionsProcessorTest {
), ),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -149,6 +200,7 @@ class SuggestionsProcessorTest {
), ),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -178,6 +230,7 @@ class SuggestionsProcessorTest {
), ),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -198,6 +251,7 @@ class SuggestionsProcessorTest {
), ),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -227,6 +281,7 @@ class SuggestionsProcessorTest {
), ),
currentUserId = A_USER_ID, currentUserId = A_USER_ID,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -240,6 +295,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -257,6 +313,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = UserId("@alice:server.org"), currentUserId = UserId("@alice:server.org"),
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -270,6 +327,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -283,6 +341,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -296,6 +355,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -313,6 +373,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { true }, canSendRoomMention = { true },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -331,6 +392,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(), roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2, currentUserId = A_USER_ID_2,
canSendRoomMention = { false }, canSendRoomMention = { false },
isInThread = false,
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(

View file

@ -12,6 +12,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.IntentionalMention 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.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.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
@ -154,10 +155,10 @@ class TimelineControllerTest {
@Test @Test
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { 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) 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) Result.success(Unit)
} }
val liveTimeline = FakeTimeline(name = "live").apply { val liveTimeline = FakeTimeline(name = "live").apply {

View file

@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data object NotificationTroubleshoot : InitialTarget data object NotificationTroubleshoot : InitialTarget
@Parcelize
data object DeveloperSettings : InitialTarget
} }
data class Params(val initialElement: InitialTarget) : NodeInputs 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.Root -> PreferencesFlowNode.NavTarget.Root
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings
} }

View file

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

View file

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

View file

@ -388,6 +388,10 @@ class RoomDetailsFlowNode(
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList()) callback.navigateToRoom(roomId, emptyList())
} }
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
} }
return messagesEntryPoint.createNode( return messagesEntryPoint.createNode(
parentNode = this, parentNode = this,

View file

@ -69,6 +69,7 @@ class DefaultRoomDetailsEntryPointTest {
} }
val callback = object : RoomDetailsEntryPoint.Callback { val callback = object : RoomDetailsEntryPoint.Callback {
override fun navigateToGlobalNotificationSettings() = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError() override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()

View file

@ -2,16 +2,17 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Slet nøglelager"</string> <string name="screen_chat_backup_key_backup_action_disable">"Slet nøglelager"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Aktivér sikkerhedskopiering"</string> <string name="screen_chat_backup_key_backup_action_enable">"Aktivér sikkerhedskopiering"</string>
<string name="screen_chat_backup_key_backup_description">"Gem din kryptografiske identitet og meddelelsesnøgler sikkert på serveren. Dette giver dig mulighed for at se din meddelelseshistorik på alle nye enheder. %1$s."</string> <string name="screen_chat_backup_key_backup_description">"Dette giver dig mulighed for at se din chathistorik på alle nye enheder og er påkrævet til sikkerhedskopiering af chats og digital identitet.%1$s ."</string>
<string name="screen_chat_backup_key_backup_title">"Nøgleopbevaring"</string> <string name="screen_chat_backup_key_backup_title">"Nøgleopbevaring"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Nøglelagring skal være slået til for at konfigurere gendannelse."</string> <string name="screen_chat_backup_key_storage_disabled_error">"Nøglelagring skal være slået til for at konfigurere gendannelse af dine samtaler."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Upload nøgler fra denne enhed"</string> <string name="screen_chat_backup_key_storage_toggle_description">"Upload nøgler fra denne enhed"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Tillad lagring af nøgler"</string> <string name="screen_chat_backup_key_storage_toggle_title">"Tillad lagring af nøgler"</string>
<string name="screen_chat_backup_recovery_action_change">"Skift gendannelsesnøgle"</string> <string name="screen_chat_backup_recovery_action_change">"Skift gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Gendan din kryptografiske identitet og beskedhistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string> <string name="screen_chat_backup_recovery_action_change_description">"Dine samtaler sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Indtast gendannelsesnøgle"</string> <string name="screen_chat_backup_recovery_action_confirm">"Indtast gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Din nøglelagring er i øjeblikket ikke synkroniseret."</string> <string name="screen_chat_backup_recovery_action_confirm_description">"Din nøglelagring er i øjeblikket ikke synkroniseret."</string>
<string name="screen_chat_backup_recovery_action_setup">"Opsæt gendannelse"</string> <string name="screen_chat_backup_recovery_action_setup">"Hent gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Dine chats sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Åbn %1$s på en stationær enhed"</string> <string name="screen_create_new_recovery_key_list_item_1">"Åbn %1$s på en stationær enhed"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Log ind på din konto igen"</string> <string name="screen_create_new_recovery_key_list_item_2">"Log ind på din konto igen"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Når du bliver bedt om at verificere din enhed, skal du vælge %1$s"</string> <string name="screen_create_new_recovery_key_list_item_3">"Når du bliver bedt om at verificere din enhed, skal du vælge %1$s"</string>
@ -23,12 +24,12 @@
<string name="screen_encryption_reset_bullet_1">"Dine kontodetaljer, kontakter, personlige indstilliger og samtaler vil blive gemt"</string> <string name="screen_encryption_reset_bullet_1">"Dine kontodetaljer, kontakter, personlige indstilliger og samtaler vil blive gemt"</string>
<string name="screen_encryption_reset_bullet_2">"Du mister al beskedhistorik, der kun er gemt på serveren."</string> <string name="screen_encryption_reset_bullet_2">"Du mister al beskedhistorik, der kun er gemt på serveren."</string>
<string name="screen_encryption_reset_bullet_3">"Du bliver nødt til at verificere alle dine eksisterende enheder og kontakter påny"</string> <string name="screen_encryption_reset_bullet_3">"Du bliver nødt til at verificere alle dine eksisterende enheder og kontakter påny"</string>
<string name="screen_encryption_reset_footer">"Nulstil kun din identitet, hvis du ikke har adgang til en anden enhed, der er logget ind, og du har mistet din gendannelsesnøgle."</string> <string name="screen_encryption_reset_footer">"Nulstil kun din digitale identitet, hvis du ikke har adgang til en anden enhed, der er logget ind, og du har mistet din gendannelsesnøgle."</string>
<string name="screen_encryption_reset_title">"Kan du ikke bekræfte? Du skal nulstille din identitet."</string> <string name="screen_encryption_reset_title">"Kan du ikke bekræfte? Du er nødt til at nulstille din digitale identitet."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slå fra"</string> <string name="screen_key_backup_disable_confirmation_action_turn_off">"Slet"</string>
<string name="screen_key_backup_disable_confirmation_description">"Du mister dine krypterede meddelelser, hvis du er logget ud af alle enheder."</string> <string name="screen_key_backup_disable_confirmation_description">"Du mister din krypterede chathistorik og skal nulstille din digitale identitet, hvis du fjerner alle dine enheder."</string>
<string name="screen_key_backup_disable_confirmation_title">"Er du sikker på, at du vil slå sikkerhedskopiering fra?"</string> <string name="screen_key_backup_disable_confirmation_title">"Er du sikker på, at du vil slette nøglelageret?"</string>
<string name="screen_key_backup_disable_description">"Hvis du sletter nøglelageret, fjernes din kryptografiske identitet og meddelelsesnøgler fra serveren og følgende sikkerhedsfunktioner deaktiveres:"</string> <string name="screen_key_backup_disable_description">"Hvis du sletter nøglelageret, fjernes din kryptografiske identitet og beskednøgler fra serveren og følgende sikkerhedsfunktioner deaktiveres:"</string>
<string name="screen_key_backup_disable_description_point_1">"Du vil ikke kunne se historikken for krypterede beskeder på nye enheder"</string> <string name="screen_key_backup_disable_description_point_1">"Du vil ikke kunne se historikken for krypterede beskeder på nye enheder"</string>
<string name="screen_key_backup_disable_description_point_2">"Du mister adgangen til dine krypterede meddelelser, hvis du er logget ud %1$s overalt"</string> <string name="screen_key_backup_disable_description_point_2">"Du mister adgangen til dine krypterede meddelelser, hvis du er logget ud %1$s overalt"</string>
<string name="screen_key_backup_disable_title">"Er du sikker på, at du vil deaktivere nøglelagring og slette lageret?"</string> <string name="screen_key_backup_disable_title">"Er du sikker på, at du vil deaktivere nøglelagring og slette lageret?"</string>
@ -58,12 +59,12 @@
<string name="screen_recovery_key_setup_generate_key">"Generer din gendannelsesnøgle"</string> <string name="screen_recovery_key_setup_generate_key">"Generer din gendannelsesnøgle"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Del ikke dette med nogen!"</string> <string name="screen_recovery_key_setup_generate_key_description">"Del ikke dette med nogen!"</string>
<string name="screen_recovery_key_setup_success">"Opsætning af gendannelse lykkedes"</string> <string name="screen_recovery_key_setup_success">"Opsætning af gendannelse lykkedes"</string>
<string name="screen_recovery_key_setup_title">"Opsæt gendannelse"</string> <string name="screen_recovery_key_setup_title">"Hent gendannelsesnøgle"</string>
<string name="screen_reset_encryption_confirmation_alert_action">"Ja, nulstil nu"</string> <string name="screen_reset_encryption_confirmation_alert_action">"Ja, nulstil nu"</string>
<string name="screen_reset_encryption_confirmation_alert_subtitle">"Denne proces er irreversibel."</string> <string name="screen_reset_encryption_confirmation_alert_subtitle">"Denne proces er irreversibel."</string>
<string name="screen_reset_encryption_confirmation_alert_title">"Er du sikker på, at du ønsker at nulstille din identitet?"</string> <string name="screen_reset_encryption_confirmation_alert_title">"Er du sikker på, at du vil nulstille din digitale identitet?"</string>
<string name="screen_reset_encryption_password_error">"Der opstod en ukendt fejl. Kontroller, at adgangskoden til din konto er korrekt, og prøv igen."</string> <string name="screen_reset_encryption_password_error">"Der opstod en ukendt fejl. Kontroller, at adgangskoden til din konto er korrekt, og prøv igen."</string>
<string name="screen_reset_encryption_password_placeholder">"Indtast…"</string> <string name="screen_reset_encryption_password_placeholder">"Indtast…"</string>
<string name="screen_reset_encryption_password_subtitle">"Bekræft, at du ønsker at nulstille din identitet."</string> <string name="screen_reset_encryption_password_subtitle">"Bekræft, at du ønsker at nulstille din digitale identitet."</string>
<string name="screen_reset_encryption_password_title">"Indtast adgangskoden til din konto for at fortsætte"</string> <string name="screen_reset_encryption_password_title">"Indtast adgangskoden til din konto for at fortsætte"</string>
</resources> </resources>

View file

@ -12,6 +12,7 @@
<string name="screen_chat_backup_recovery_action_confirm">"Введите ключ восстановления"</string> <string name="screen_chat_backup_recovery_action_confirm">"Введите ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"В настоящее время резервная копия ваших чатов не синхронизирована."</string> <string name="screen_chat_backup_recovery_action_confirm_description">"В настоящее время резервная копия ваших чатов не синхронизирована."</string>
<string name="screen_chat_backup_recovery_action_setup">"Получить ключ восстановления"</string> <string name="screen_chat_backup_recovery_action_setup">"Получить ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Ваши чаты автоматически резервируются с использованием сквозного шифрования. Для восстановления этой резервной копии и сохранения вашей цифровой личности в случае потери доступа ко всем вашим устройствам вам потребуется ключ восстановления."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Откройте %1$s на компьютере"</string> <string name="screen_create_new_recovery_key_list_item_1">"Откройте %1$s на компьютере"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Войдите в свой аккаунт еще раз"</string> <string name="screen_create_new_recovery_key_list_item_2">"Войдите в свой аккаунт еще раз"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Когда потребуется подтвердить устройство, выберите %1$s"</string> <string name="screen_create_new_recovery_key_list_item_3">"Когда потребуется подтвердить устройство, выберите %1$s"</string>
@ -25,7 +26,7 @@
<string name="screen_encryption_reset_bullet_3">"Вам нужно будет заново подтвердить все существующие устройства и контакты."</string> <string name="screen_encryption_reset_bullet_3">"Вам нужно будет заново подтвердить все существующие устройства и контакты."</string>
<string name="screen_encryption_reset_footer">"Сбрасывайте личность только в том случае, если у вас нет доступа к другим устройству, на которых выполнен вход, и вы потеряли ключ восстановления."</string> <string name="screen_encryption_reset_footer">"Сбрасывайте личность только в том случае, если у вас нет доступа к другим устройству, на которых выполнен вход, и вы потеряли ключ восстановления."</string>
<string name="screen_encryption_reset_title">"Не можете подтвердить? Вам потребуется сбросить личность вашей учетной записи."</string> <string name="screen_encryption_reset_title">"Не можете подтвердить? Вам потребуется сбросить личность вашей учетной записи."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Выключить"</string> <string name="screen_key_backup_disable_confirmation_action_turn_off">"Удалить"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string> <string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</string> <string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</string>
<string name="screen_key_backup_disable_description">"Удаление хранилища ключей приведёт к удалению вашей криптографической личности и ключей сообщений с сервера, а также отключению следующих функций безопасности:"</string> <string name="screen_key_backup_disable_description">"Удаление хранилища ключей приведёт к удалению вашей криптографической личности и ключей сообщений с сервера, а также отключению следующих функций безопасности:"</string>

View file

@ -76,7 +76,7 @@ class SharePresenterTest {
fun `present - on room selected ok`() = runTest { fun `present - on room selected ok`() = runTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) } sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
}, },
) )
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
@ -103,7 +103,7 @@ class SharePresenterTest {
fun `present - send text ok`() = runTest { fun `present - send text ok`() = runTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) } sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
}, },
) )
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string>
<string name="screen_identity_confirmation_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string> <string name="screen_identity_confirmation_subtitle">"Vælg, hvordan du vil verificere dig for at konfigurere sikre beskeder."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</string> <string name="screen_identity_confirmation_title">"Bekræft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string> <string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string> <string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string> <string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>
@ -17,7 +17,7 @@
<string name="screen_session_verification_compare_numbers_subtitle">"Bekræft, at numrene nedenfor stemmer overens med dem, der vises på din anden session."</string> <string name="screen_session_verification_compare_numbers_subtitle">"Bekræft, at numrene nedenfor stemmer overens med dem, der vises på din anden session."</string>
<string name="screen_session_verification_compare_numbers_title">"Sammenlign tal"</string> <string name="screen_session_verification_compare_numbers_title">"Sammenlign tal"</string>
<string name="screen_session_verification_complete_subtitle">"Nu kan du læse eller sende beskeder sikkert med din anden enhed."</string> <string name="screen_session_verification_complete_subtitle">"Nu kan du læse eller sende beskeder sikkert med din anden enhed."</string>
<string name="screen_session_verification_complete_user_subtitle">"Nu kan du stole på identiteten af denne bruger, når I sender og modtager beskeder fra hinanden."</string> <string name="screen_session_verification_complete_user_subtitle">"Nu kan du stole på denne brugers digitale identitet, når I sender eller modtager beskeder."</string>
<string name="screen_session_verification_device_verified">"Enhed verificeret"</string> <string name="screen_session_verification_device_verified">"Enhed verificeret"</string>
<string name="screen_session_verification_enter_recovery_key">"Indtast gendannelsesnøgle"</string> <string name="screen_session_verification_enter_recovery_key">"Indtast gendannelsesnøgle"</string>
<string name="screen_session_verification_failed_subtitle">"Enten udløb anmodningen, den blev afvist, eller der var en fejl i verifikationen."</string> <string name="screen_session_verification_failed_subtitle">"Enten udløb anmodningen, den blev afvist, eller der var en fejl i verifikationen."</string>
@ -42,7 +42,7 @@
<string name="screen_session_verification_use_another_device_title">"Åbn appen på en anden bekræftet enhed"</string> <string name="screen_session_verification_use_another_device_title">"Åbn appen på en anden bekræftet enhed"</string>
<string name="screen_session_verification_user_initiator_subtitle">"For ekstra sikkerhed, verificér denne bruger ved at sammenligne et sæt emojier på jeres enheder. Gør dette ved at bruge en kommunikationsmetode i stoler på."</string> <string name="screen_session_verification_user_initiator_subtitle">"For ekstra sikkerhed, verificér denne bruger ved at sammenligne et sæt emojier på jeres enheder. Gør dette ved at bruge en kommunikationsmetode i stoler på."</string>
<string name="screen_session_verification_user_initiator_title">"Verificér denne bruger?"</string> <string name="screen_session_verification_user_initiator_title">"Verificér denne bruger?"</string>
<string name="screen_session_verification_user_responder_subtitle">"For ekstra sikkerhed ønsker en anden bruger at bekræfte din identitet. Du får vist et sæt emojier til sammenligning."</string> <string name="screen_session_verification_user_responder_subtitle">"For ekstra sikkerhed ønsker en anden bruger at bekræfte din digitale identitet. I vil blive vist et sæt emojis, der skal sammenlignes."</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"Du burde se en popup på den anden enhed. Start verifikationen derfra nu."</string> <string name="screen_session_verification_waiting_another_device_subtitle">"Du burde se en popup på den anden enhed. Start verifikationen derfra nu."</string>
<string name="screen_session_verification_waiting_another_device_title">"Start verifikation på den anden enhed"</string> <string name="screen_session_verification_waiting_another_device_title">"Start verifikation på den anden enhed"</string>
<string name="screen_session_verification_waiting_other_device_title">"Start verifikation på den anden enhed"</string> <string name="screen_session_verification_waiting_other_device_title">"Start verifikation på den anden enhed"</string>
@ -50,5 +50,5 @@
<string name="screen_session_verification_waiting_subtitle">"Når du er blevet accepteret, kan du fortsætte med verifikationen."</string> <string name="screen_session_verification_waiting_subtitle">"Når du er blevet accepteret, kan du fortsætte med verifikationen."</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Accepter anmodningen om at starte bekræftelsesprocessen i din anden session for at fortsætte."</string> <string name="screen_session_verification_waiting_to_accept_subtitle">"Accepter anmodningen om at starte bekræftelsesprocessen i din anden session for at fortsætte."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Venter på at acceptere anmodningen"</string> <string name="screen_session_verification_waiting_to_accept_title">"Venter på at acceptere anmodningen"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string> <string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources> </resources>

View file

@ -11,7 +11,7 @@ ksp = "2.3.6"
firebaseAppDistribution = "5.2.1" firebaseAppDistribution = "5.2.1"
# AndroidX # AndroidX
core = "1.17.0" core = "1.18.0"
datastore = "1.2.1" datastore = "1.2.1"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1" constraintlayout_compose = "1.1.1"
@ -19,10 +19,10 @@ lifecycle = "2.10.0"
activity = "1.13.0" activity = "1.13.0"
media3 = "1.9.3" media3 = "1.9.3"
camera = "1.5.3" camera = "1.5.3"
work = "2.11.1" work = "2.11.2"
# Compose # Compose
compose_bom = "2026.03.00" compose_bom = "2026.03.01"
# Coroutines # Coroutines
coroutines = "1.10.2" coroutines = "1.10.2"
@ -54,7 +54,7 @@ haze = "1.7.2"
dependencyAnalysis = "3.6.1" dependencyAnalysis = "3.6.1"
# DI # DI
metro = "0.11.4" metro = "0.12.0"
# Auto service # Auto service
autoservice = "1.1.1" autoservice = "1.1.1"
@ -64,7 +64,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/ # See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0" ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.7" kover = "0.9.8"
[libraries] [libraries]
# Project # Project
@ -84,7 +84,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:34.11.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
google_tink = "com.google.crypto.tink:tink-android:1.20.0" google_tink = "com.google.crypto.tink:tink-android:1.21.0"
# AndroidX # AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_core = { module = "androidx.core:core", version.ref = "core" }
@ -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_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" 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_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_splash = "androidx.core:core-splashscreen:1.2.0" androidx_splash = "androidx.core:core-splashscreen:1.2.0"
@ -178,7 +178,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should # All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs. # only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.24" matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.31"
# Others # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@ -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-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-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" } 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" sqlite = "androidx.sqlite:sqlite-ktx:2.6.2"
unifiedpush = "org.unifiedpush.android:connector:3.3.2" unifiedpush = "org.unifiedpush.android:connector:3.3.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@ -220,13 +220,13 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref =
color_picker = "io.mhssn:colorpicker:1.0.0" color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.37.0" posthog = "com.posthog:posthog-android:3.39.0"
sentry = "io.sentry:sentry-android:8.36.0" sentry = "io.sentry:sentry-android:8.36.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.33.2" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.33.2"
# Emojibase # Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.1" matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.3"
sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
# Di # Di

View file

@ -16,9 +16,11 @@ import android.view.ViewTreeObserver
import android.view.WindowInsets import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
fun View.hideKeyboard() { fun View.hideKeyboard() {
val imm = context?.getSystemService<InputMethodManager>() val imm = context?.getSystemService<InputMethodManager>()
@ -26,29 +28,39 @@ fun View.hideKeyboard() {
} }
suspend fun View.hideKeyboardAndAwaitAnimation() { suspend fun View.hideKeyboardAndAwaitAnimation() {
val imm = context?.getSystemService<InputMethodManager>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !rootWindowInsets.isVisible(WindowInsets.Type.ime())) {
// Keyboard is already hidden, no need to do anything
return
}
val mutex = Mutex() val imm = context?.getSystemService<InputMethodManager>() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val future = CompletableDeferred<Unit>()
val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { view, insets -> setOnApplyWindowInsetsListener { view, insets ->
if (!insets.isVisible(WindowInsets.Type.ime())) { if (!insets.isVisible(WindowInsets.Type.ime())) {
mutex.unlock() future.complete(Unit)
// Remove the listener now, it's a single use operation
setOnApplyWindowInsetsListener(null)
} }
insets insets
} }
imm?.hideSoftInputFromWindow(windowToken, 0) imm.hideSoftInputFromWindow(windowToken, 0)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) { imm.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || resultCode == InputMethodManager.RESULT_HIDDEN) {
resultCode == InputMethodManager.RESULT_HIDDEN) { future.complete(Unit)
mutex.unlock()
} }
} }
}) })
} }
mutex.lock()
if (requested) {
// Await the future to ensure the keyboard hide animation has completed before proceeding
withTimeoutOrNull(1.seconds) { future.await() }
}
} }
fun View.showKeyboard(andRequestFocus: Boolean = false) { fun View.showKeyboard(andRequestFocus: Boolean = false) {

View file

@ -16,7 +16,10 @@ import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitVerticalPointerSlopOrCancellation
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -41,10 +44,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
.run { .run {
if (isSwipeGestureEnabled) { if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) { pointerInput(maxBottomSheetContentHeight) {
detectVerticalDragGestures( customDetectVerticalDragGestures(
onVerticalDrag = { _, dragAmount -> onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt()) val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight) val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
animatable.animateTo(destination) animatable.animateTo(destination)
} }
} },
canScroll = {
// We only consider we can scroll in the contents if the min size matches the max size so it's maximized
minBottomContentHeightPx == calculatedMaxBottomContentHeightPx
},
) )
} }
} else { } else {
@ -189,6 +200,45 @@ fun ExpandableBottomSheetLayout(
) )
} }
// The original detectVerticalDragGestures doesn't allow us to conditionally consume the initial slop event that triggers the drag,
// which is necessary in our case to allow inner scrollables to work when the sheet is not fully expanded, so we need to re-implement it here
private suspend fun PointerInputScope.customDetectVerticalDragGestures(
onDragStart: (Offset) -> Unit = {},
onDragEnd: () -> Unit = {},
onDragCancel: () -> Unit = {},
canScroll: () -> Boolean = { false },
onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var overSlop = 0f
val drag =
awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
// Consuming this event is what triggers the dragging instead of the inner content scrolling
// We should only consume it if we can't scroll in the inner content so we drag the bottom sheet instead, otherwise we let it pass through
// This is the only change compared to the original detectVerticalDragGestures implementation
if (!canScroll()) {
change.consume()
}
overSlop = over
}
if (drag != null) {
onDragStart.invoke(drag.position)
onVerticalDrag.invoke(drag, overSlop)
if (
verticalDrag(drag.id) {
onVerticalDrag(it, it.positionChange().y)
it.consume()
}
) {
onDragEnd()
} else {
onDragCancel()
}
}
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@Suppress("UnusedPrivateMember") @Suppress("UnusedPrivateMember")

View file

@ -32,9 +32,13 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.withSave import androidx.core.graphics.withSave
import coil3.Image import coil3.Image
import coil3.ImageLoader import coil3.ImageLoader
@ -50,6 +54,7 @@ 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.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
private val PIN_WIDTH = 42.dp private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f private val PIN_HEIGHT = PIN_WIDTH * 1.2f
@ -99,21 +104,33 @@ fun LocationPin(
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? { fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant) val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant) val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) { val resources = LocalResources.current
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey) return if (LocalInspectionMode.current) {
if (cached != null) { // In preview mode, skip async loading and return a simple placeholder image instead to avoid using ImageLoader
value = cached.image.toBitmap().asImageBitmap() val dimensions = PinDimensions(density)
} else { val avatarImage = ResourcesCompat.getDrawable(resources, CommonDrawables.sample_avatar, context.theme)?.toBitmap()?.asImage()
val dimensions = PinDimensions(density) LocationPinRenderer.renderPin(variant, colors, dimensions, avatarImage).asImageBitmap()
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader) } else {
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage())) produceState<ImageBitmap?>(initialValue = null, cacheKey) {
value = bitmap.asImageBitmap() val imageLoader = SingletonImageLoader.get(context)
} val memoryCacheKey = MemoryCache.Key(cacheKey)
}.value val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = with(LocationPinRenderer) {
val avatarImage = loadAvatarImage(variant, context, imageLoader)
renderPin(variant, colors, dimensions, avatarImage)
}
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
} }
@Composable @Composable
@ -208,19 +225,17 @@ private object LocationPinRenderer {
/** /**
* Renders a pin variant to bitmap. Suspending for async avatar loading. * Renders a pin variant to bitmap. Suspending for async avatar loading.
*/ */
suspend fun renderPin( fun renderPin(
variant: PinVariant, variant: PinVariant,
colors: PinColors, colors: PinColors,
dimensions: PinDimensions, dimensions: PinDimensions,
context: Context, avatarImage: Image?,
imageLoader: ImageLoader,
): Bitmap { ): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt()) val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions) canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) { when (variant) {
is PinVariant.UserLocation -> { is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar( canvas.drawAvatar(
avatarImage = avatarImage, avatarImage = avatarImage,
avatarData = variant.avatarData, avatarData = variant.avatarData,
@ -284,11 +299,15 @@ private object LocationPinRenderer {
return path return path
} }
private suspend fun loadAvatarImage( suspend fun loadAvatarImage(
avatarData: AvatarData, variant: PinVariant,
context: Context, context: Context,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Image? { ): Image? {
val avatarData = when (variant) {
is PinVariant.UserLocation -> variant.avatarData
else -> return null
}
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(avatarData) .data(avatarData)
// Disable hardware rendering for Canvas // Disable hardware rendering for Canvas

View file

@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
val SemanticColors.pinnedMessageBannerBorder val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400 get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
val SemanticColors.floatingDateBadgeBackground
get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ColorAliasesPreview() = ElementPreview { internal fun ColorAliasesPreview() = ElementPreview {

View file

@ -163,10 +163,24 @@ enum class FeatureFlags(
), ),
ValidateNetworkWhenSchedulingNotificationFetching( ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching", key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching", title = "Validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " + description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.", "Enabling this can be problematic in air-gapped environments.",
defaultValue = { true }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
FloatingDateBadge(
key = "feature.floating_date_badge",
title = "Display sticky date headers in the timeline",
description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
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 Id -> roomId.value
is Alias -> roomAlias.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) fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)

View file

@ -17,3 +17,18 @@ interface MxcTools {
*/ */
fun mxcUri2FilePath(mxcUri: String): String? 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, body: String,
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): Result<Unit> ): Result<Unit>
suspend fun editMessage( suspend fun editMessage(
@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false, fromNotification: Boolean = false,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result<Unit> ): Result<Unit>
suspend fun sendImage( 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.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType 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.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.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline 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.TimelineException
@ -271,8 +272,16 @@ class RustTimeline(
body: String, body: String,
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) { ): 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> { runCatchingExceptions<Unit> {
inner.send(content) inner.send(content)
} }
@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) { ): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions { runCatchingExceptions {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions) val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
)
inner.sendReply( inner.sendReply(
msg = msg, msg = msg,
eventId = repliedToEventId.value, eventId = repliedToEventId.value,

View file

@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention 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 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.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown 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. * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/ */
object MessageEventContent { object MessageEventContent {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation { fun from(
return if (htmlBody != null) { body: String,
messageEventContentFromHtml(body, htmlBody) htmlBody: String?,
} else { intentionalMentions: List<IntentionalMention>,
messageEventContentFromMarkdown(body) msgType: MsgType = MsgType.MSG_TYPE_TEXT,
}.withMentions(intentionalMentions.map()) 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.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType 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.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.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -64,7 +65,9 @@ class FakeTimeline(
body: String, body: String,
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ -> msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError() lambdaError()
} }
@ -76,8 +79,10 @@ class FakeTimeline(
body: String, body: String,
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask { ): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions) sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
} }
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ -> var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ -> msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError() lambdaError()
} }
@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?, htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda( ): Result<Unit> = replyMessageLambda(
repliedToEventId, repliedToEventId,
body, body,
htmlBody, htmlBody,
intentionalMentions, intentionalMentions,
fromNotification, fromNotification,
msgType,
) )
var sendImageLambda: ( var sendImageLambda: (

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocus
@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
mutableStateOf(null) mutableStateOf(null)
} }
val isTargetVisible = LocalNodeTargetVisibility.current
val playableState: PlayableState.Playable by remember { val playableState: PlayableState.Playable by remember {
derivedStateOf { derivedStateOf {
PlayableState.Playable( PlayableState.Playable(
@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
exoPlayer.pause() exoPlayer.pause()
} }
} }
LaunchedEffect(isTargetVisible) {
if (!isTargetVisible) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) { if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) { LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri) val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
} }
} else { } else {
exoPlayer.setMediaItems(emptyList()) LaunchedEffect(Unit) {
exoPlayer.setMediaItems(emptyList())
}
} }
val context = LocalContext.current val context = LocalContext.current
val waveform = info?.waveform val waveform = info?.waveform
@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
} }
}, },
update = { playerView -> update = { playerView ->
playerView.isVisible = metadata.hasArtwork() playerView.isVisible = metadata.hasArtwork() && isTargetVisible
}, },
onRelease = { playerView -> onRelease = { playerView ->
playerView.player = null playerView.player = null
@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
) )
} }
OnLifecycleEvent { _, event -> DisposableEffect(exoPlayer) {
when (event) { exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener) onDispose {
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare() if (!exoPlayer.isReleased) {
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener) exoPlayer.removeListener(playerListener)
exoPlayer.release()
} }
else -> Unit }
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
exoPlayer.pause()
} }
} }
} }

View file

@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.PushHandlingWakeLock import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration import kotlin.time.Duration
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -22,24 +21,13 @@ import kotlin.time.Duration
class DefaultPushHandlingWakeLock( class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : PushHandlingWakeLock { ) : PushHandlingWakeLock {
private val count = AtomicInteger(0)
override fun lock(time: Duration) { override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.") Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context) FetchPushForegroundService.startIfNeeded(context)
count.incrementAndGet()
} }
override suspend fun unlock() { override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.") Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context) 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

@ -17,6 +17,7 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock import io.element.android.libraries.push.api.push.PushHandlingWakeLock
@ -57,6 +58,8 @@ class FetchPushForegroundService : Service() {
} }
} }
private var isOnForeground = false
override fun onCreate() { override fun onCreate() {
Timber.d("Creating FetchPushForegroundService") Timber.d("Creating FetchPushForegroundService")
@ -71,12 +74,32 @@ class FetchPushForegroundService : Service() {
.setVibrate(longArrayOf(0)) .setVibrate(longArrayOf(0))
.setSound(null) .setSound(null)
.build() .build()
startForeground(NOTIFICATION_ID, notificationCompat)
// Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
// In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
// from running in foreground later.
runCatchingExceptions {
startForeground(NOTIFICATION_ID, notificationCompat)
}
.onSuccess {
isOnForeground = true
Timber.d("FetchPushForegroundService started in foreground successfully")
}
.onFailure {
isOnForeground = false
Timber.e(it, "Failed to start FetchPushForegroundService in foreground")
}
super.onCreate() super.onCreate()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isOnForeground) {
Timber.w("FetchPushForegroundService is not running in foreground, stopping it to avoid crash")
stopSelf()
return START_NOT_STICKY
}
wakelock.acquire(wakelockTimeout) wakelock.acquire(wakelockTimeout)
// The timeout is not automatic before Android 15, so we need to schedule it ourselves // The timeout is not automatic before Android 15, so we need to schedule it ourselves
@ -91,18 +114,21 @@ class FetchPushForegroundService : Service() {
} }
override fun stopService(intent: Intent?): Boolean { override fun stopService(intent: Intent?): Boolean {
wakelock.release() if (isOnForeground) {
wakelock.release()
stopForeground(STOP_FOREGROUND_REMOVE)
}
stopForeground(STOP_FOREGROUND_REMOVE)
return super.stopService(intent) return super.stopService(intent)
} }
override fun onTimeout(startId: Int) { override fun onTimeout(startId: Int) {
super.onTimeout(startId) super.onTimeout(startId)
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService") if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() } coroutineScope.launch { pushHandlingWakeLock.unlock() }
}
} }
companion object { companion object {
@ -119,7 +145,13 @@ class FetchPushForegroundService : Service() {
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java) val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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 { } else {
context.startService(intent) 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.core.ThreadId
import io.element.android.libraries.matrix.api.room.IntentionalMention 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.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.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply`() = runTest { 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 = 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 { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
replyMessageLambda = replyMessage replyMessageLambda = replyMessage
@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest {
advanceUntilIdle() advanceUntilIdle()
sendMessage.assertions() sendMessage.assertions()
.isCalledOnce() .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() onNotifiableEventsReceivedResult.assertions()
.isCalledOnce() .isCalledOnce()
replyMessage.assertions() replyMessage.assertions()
@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply blank message`() = runTest { 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 { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
} }
@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply to thread`() = runTest { 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 = 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 { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
replyMessageLambda = replyMessage replyMessageLambda = replyMessage
@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
value(A_MESSAGE), value(A_MESSAGE),
value(null), value(null),
value(emptyList<IntentionalMention>()), value(emptyList<IntentionalMention>()),
value(true) value(true),
value(MsgType.MSG_TYPE_TEXT),
) )
} }

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.pushproviders.firebase
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.google.firebase.messaging.RemoteMessage.PRIORITY_HIGH
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
@ -45,8 +46,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}") Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}")
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work val isHighPriority = message.priority == PRIORITY_HIGH
pushHandlingWakeLock.lock() if (isHighPriority) {
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
pushHandlingWakeLock.lock()
}
coroutineScope.launch { coroutineScope.launch {
val pushData = pushParser.parse(message.data) val pushData = pushParser.parse(message.data)
@ -58,7 +62,9 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
"$it: ${message.data[it]}" "$it: ${message.data[it]}"
}, },
) )
pushHandlingWakeLock.unlock() if (isHighPriority) {
pushHandlingWakeLock.unlock()
}
} else { } else {
val handled = pushHandler.handle( val handled = pushHandler.handle(
pushData = pushData, pushData = pushData,
@ -66,7 +72,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
) )
// If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long. // If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long.
if (!handled) { if (!handled && isHighPriority) {
pushHandlingWakeLock.unlock() pushHandlingWakeLock.unlock()
} }
} }

View file

@ -96,6 +96,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value) putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value) putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET) putString("cs", A_SECRET)
putString("google.priority", "high")
}, },
) )
) )
@ -127,6 +128,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value) putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value) putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET) putString("cs", A_SECRET)
putString("google.priority", "high")
}, },
) )
) )
@ -141,6 +143,33 @@ class VectorFirebaseMessagingServiceTest {
unlockLambda.assertions().isCalledOnce() unlockLambda.assertions().isCalledOnce()
} }
@Test
fun `test pushHandler with a remote message with normal priority won't lock the wakelock`() = runTest {
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleResult = { _, _ -> false }),
pushHandlingWakeLock = FakePushHandlingWakeLock(
lock = lockLambda,
unlock = unlockLambda
)
)
vectorFirebaseMessagingService.onMessageReceived(
message = RemoteMessage(
Bundle().apply {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "normal")
},
)
)
// The wakelock should not be locked
lockLambda.assertions().isNeverCalled()
unlockLambda.assertions().isNeverCalled()
}
@Test @Test
fun `test new token is forwarded to the handler`() = runTest { fun `test new token is forwarded to the handler`() = runTest {
val lambda = lambdaRecorder<String, Unit> { } val lambda = lambdaRecorder<String, Unit> { }

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

View file

@ -0,0 +1,224 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.slashcommands.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.slashcommands.api.ChatEffect
import io.element.android.libraries.slashcommands.api.MessagePrefix
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CommandParserTest {
@Test
fun parseSlashCommandEmpty() = runTest {
test("/", SlashCommand.ErrorEmptySlashCommand("A string/"))
}
@Test
fun parseSlashCommandUnknown() = runTest {
test("/unknown", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
test("/unknown with param", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
}
@Test
fun parseSlashCommandNotACommand() = runTest {
test("", SlashCommand.NotACommand)
test("test", SlashCommand.NotACommand)
test("// test", SlashCommand.NotACommand)
}
@Test
fun parseSlashCommandEmote() = runTest {
test("/me test", SlashCommand.SendEmote("test"))
test("/me", SlashCommand.ErrorSyntax("A string/me, /me <message>"))
}
@Test
fun parseSlashCommandRemove() = runTest {
// Nominal
test("/remove $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
// With a reason
test("/remove $A_USER_ID a reason", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
// Trim the reason
test("/remove $A_USER_ID a reason ", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
// Alias
test("/kick $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
// Error
test("/remove", SlashCommand.ErrorSyntax("A string/remove, /remove <user-id> [reason]"))
}
@Test
fun parseSlashCommandRemoveMarkdown() = runTest {
// Nominal
test(
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org)",
SlashCommand.RemoveUser(UserId("@user:domain.org"), null)
)
test(
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org) reason",
SlashCommand.RemoveUser(UserId("@user:domain.org"), "reason")
)
}
@Test
fun parseSlashCommandPlainAndNick() = runTest {
test("/plain hello", SlashCommand.SendPlainText("hello"))
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
test("/nick John", SlashCommand.ChangeDisplayName("John"))
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
}
@Test
fun parseSlashCommandRoomNickAndAvatars() = runTest {
test("/myroomnick Roomy", SlashCommand.ChangeDisplayNameForRoom("Roomy"))
test("/roomavatar mxc://matrix.org/abc", SlashCommand.ChangeRoomAvatar("mxc://matrix.org/abc"))
test("/roomavatar http://notmxc", SlashCommand.ErrorSyntax("A string/roomavatar, /roomavatar <mxc_url>"))
test("/myroomavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatarForRoom("mxc://matrix.org/abc"))
}
@Test
fun parseSlashCommandTopicAndRainbow() = runTest {
test("/topic New topic", SlashCommand.ChangeTopic("New topic"))
test("/topic", SlashCommand.ErrorSyntax("A string/topic, /topic <topic>"))
test("/rainbow yay", SlashCommand.SendRainbow("yay"))
test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow <message>"))
test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay"))
test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme <message>"))
}
@Test
fun parseSlashCommandJoinAndRoomName() = runTest {
// valid join
test(
"/join !roomId:domain reason",
SlashCommand.JoinRoom(
RoomIdOrAlias.Id(RoomId("!roomId:domain")),
"reason"
)
)
// invalid join
test("/join notavalid", SlashCommand.ErrorSyntax("A string/join, /join <room-address> [reason]"))
test("/roomname My Room", SlashCommand.ChangeRoomName("My Room"))
test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname <name>"))
}
@Test
fun parseSlashCommandInviteBanEtc() = runTest {
test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null))
test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite <user-id> [reason]"))
test("/ban $A_USER_ID bad", SlashCommand.BanUser(A_USER_ID, "bad"))
test("/unban $A_USER_ID", SlashCommand.UnbanUser(A_USER_ID, null))
test("/ignore $A_USER_ID", SlashCommand.IgnoreUser(A_USER_ID))
test("/unignore $A_USER_ID", SlashCommand.UnignoreUser(A_USER_ID))
}
@Test
fun parseSlashCommandPowerLevels() = runTest {
test("/op $A_USER_ID 50", SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
test("/op $A_USER_ID notnumber", SlashCommand.ErrorSyntax("A string/op, /op <user-id> [<power-level>]"))
test("/deop $A_USER_ID", SlashCommand.SetUserPowerLevel(A_USER_ID, null))
}
@Test
fun parseSlashCommandDevtoolsAndSpoiler() = runTest {
test("/devtools", SlashCommand.DevTools)
test("/devtools extra", SlashCommand.ErrorSyntax("A string/devtools, /devtools"))
test("/spoiler secret", SlashCommand.SendSpoiler("secret"))
test("/spoiler", SlashCommand.ErrorSyntax("A string/spoiler, /spoiler <message>"))
}
@Test
fun parseSlashCommandEmojisAndSession() = runTest {
test("/shrug hello", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "hello"))
test("/shrug", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, ""))
test("/lenny fun", SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"))
test("/tableflip wow", SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"))
test("/unflip be safe", SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "be safe"))
test("/discardsession", SlashCommand.DiscardSession)
test("/discardsession extra", SlashCommand.ErrorSyntax("A string/discardsession, /discardsession"))
}
@Test
fun parseSlashCommandWhoisAndEffectsAndLeave() = runTest {
test("/whois $A_USER_ID", SlashCommand.ShowUser(A_USER_ID))
test("/confetti party", SlashCommand.SendChatEffect(ChatEffect.CONFETTI, "party"))
test("/snowfall snow", SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, "snow"))
test("/leave", SlashCommand.LeaveRoom)
test("/leave now", SlashCommand.ErrorSyntax("A string/leave, /leave"))
}
@Test
fun parseSlashCommandUpgradeAndCrashAndFeatureFlagAndThreads() = runTest {
test("/upgraderoom 9", SlashCommand.UpgradeRoom("9"))
test("/upgraderoom", SlashCommand.ErrorSyntax("A string/upgraderoom, /upgraderoom newVersion"))
// Crash only when developer mode enabled
val cpDev = createCommandParser(appPreferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true))
try {
cpDev.parseSlashCommand("/crash", null, false)
org.junit.Assert.fail("Expected crash to throw")
} catch (_: IllegalStateException) {
// expected
}
// Feature flag disabled
val cpFF = createCommandParser(featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SlashCommand.key to false)))
val res = cpFF.parseSlashCommand("/me test", null, false)
assertThat(res).isEqualTo(SlashCommand.NotACommand)
// Not supported in threads (e.g. /join)
val cpThread = createCommandParser()
val threadRes = cpThread.parseSlashCommand("/join !roomId:domain", null, true)
assertThat(threadRes).isInstanceOf(SlashCommand.ErrorCommandNotSupportedInThreads::class.java)
assertThat((threadRes as SlashCommand.ErrorCommandNotSupportedInThreads).message).isEqualTo("A string/join")
}
private suspend fun test(message: String, expectedResult: SlashCommand) {
val commandParser = createCommandParser()
val result = commandParser.parseSlashCommand(message, null, false)
assertThat(result).isEqualTo(expectedResult)
}
}
internal fun createCommandParser(
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SlashCommand.key to true,
),
),
stringProvider: StringProvider = FakeStringProvider(),
) = CommandParser(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
stringProvider = stringProvider,
)

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