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 f19295d63d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
291 changed files with 4973 additions and 1595 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,9 @@ adb install -r $1
echo "Starting the screen recording..."
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/
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" &
set +e
~/.maestro/bin/maestro test .maestro/allTests.yaml

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,6 @@ appId: ${MAESTRO_APP_ID}
---
- takeScreenshot: build/maestro/520-Timeline
- tapOn: "Add attachment"
- tapOn: "Location"
- tapOn: "Share my location"
- tapOn: "Share location"
- tapOn: "Share selected location"
- 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
=============================

View file

@ -424,6 +424,10 @@ class LoggedInFlowNode(
override fun navigateToGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
}
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.DeveloperSettings))
}
}
val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias,
@ -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
BackStackElement(
key = NavKey(roomTarget),
fromState = CREATED,
targetState = ACTIVE,
operation = this
)
key = NavKey(roomTarget),
fromState = CREATED,
targetState = ACTIVE,
operation = this
)
} else {
// Otherwise, just push the new node to the end of the backstack
Push<LoggedInFlowNode.NavTarget>(roomTarget).invoke(currentElements)

View file

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

View file

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

View file

@ -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">
<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_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</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 digitale identitet"</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_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_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_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_submit">"Opsæt gendannelse"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</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">"Hent gendannelsesnøgle"</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_primary_button_title">"Indtast 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.location.impl.share
import app.cash.molecule.RecompositionMode
@ -37,6 +39,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule

View file

@ -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_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_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">
<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>
@ -34,5 +34,5 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
</plurals>
<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_signout_in_progress_dialog_content">"Logger ud…"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources>

View file

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<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_submit">"Log ud"</string>
<string name="screen_signout_confirmation_dialog_title">"Log ud"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</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_title">"Du har slået sikkerhedskopiering fra"</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_confirmation_dialog_content">"Er du sikker på, at ønsker at fjerne denne enhed?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Fjern denne enhed"</string>
<string name="screen_signout_confirmation_dialog_title">"Fjern denne enhed"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</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 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 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_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_preference_item">"Log ud"</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_title">"Gendannelse er ikke konfigureret"</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_preference_item">"Fjern denne enhed"</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">"Du er ved at miste adgangen til dine krypterede chats"</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>

View file

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

View file

@ -53,6 +53,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
@ -104,4 +105,5 @@ dependencies {
testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.recentemojis.test)
testImplementation(projects.libraries.slashcommands.test)
}

View file

@ -293,6 +293,10 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -502,6 +506,10 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
@ -567,7 +575,7 @@ class MessagesFlowNode(
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
mode = mode
).takeIf { locationService.isServiceAvailable() }
}
else -> null

View file

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

View file

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

View file

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

View file

@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
data object SaveDraft : MessageComposerEvent
data object ClearSlashError : MessageComposerEvent
}

View file

@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@ -33,12 +34,14 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.Attachment.Media
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
@ -68,6 +71,9 @@ import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.message
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@ -104,6 +110,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@Assisted private val timelineController: TimelineController,
@Assisted private val isInThread: Boolean,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider,
@ -125,10 +132,15 @@ class MessageComposerPresenter(
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
fun create(
timelineController: TimelineController,
navigator: MessagesNavigator,
isInThread: Boolean,
): MessageComposerPresenter
}
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
@ -218,6 +230,8 @@ class MessageComposerPresenter(
}
)
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(Unit) {
val draft = draftService.loadDraft(
roomId = room.roomId,
@ -246,12 +260,13 @@ class MessageComposerPresenter(
sessionCoroutineScope.sendMessage(
markdownTextEditorState = markdownTextEditorState,
richTextEditorState = richTextEditorState,
slashCommandAction = slashCommandAction,
)
}
is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media(
attachment = Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
mimeType = null,
@ -340,6 +355,9 @@ class MessageComposerPresenter(
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
is ResolvedSuggestion.Command -> {
richTextEditorState.replaceSuggestion(suggestion.command.command)
}
}
} else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertSuggestion(
@ -354,6 +372,9 @@ class MessageComposerPresenter(
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
}
MessageComposerEvent.ClearSlashError -> {
slashCommandAction.value = AsyncAction.Uninitialized
}
}
}
@ -385,6 +406,7 @@ class MessageComposerPresenter(
suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
slashCommandAction = slashCommandAction.value,
eventSink = ::handleEvent,
)
}
@ -422,6 +444,7 @@ class MessageComposerPresenter(
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
isInThread = isInThread,
)
suggestions.clear()
suggestions.addAll(result)
@ -433,9 +456,69 @@ class MessageComposerPresenter(
private fun CoroutineScope.sendMessage(
markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState,
slashCommandAction: MutableState<AsyncAction<Unit>>,
) = launch {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
val capturedMode = messageComposerContext.composerMode
val slashCommand = if (capturedMode is MessageComposerMode.Normal) {
slashCommandService.parse(
textMessage = message.markdown,
formattedMessage = message.html,
isInThreadTimeline = isInThread,
)
} else {
SlashCommand.NotACommand
}
when (slashCommand) {
is SlashCommand.NotACommand -> Unit
is SlashCommand.Error -> {
slashCommandAction.value = AsyncAction.Failure(Exception(slashCommand.message()))
return@launch
}
is SlashCommand.SlashCommandNavigation -> {
when (slashCommand) {
is SlashCommand.ShowUser -> {
navigator.navigateToMember(slashCommand.userId)
}
SlashCommand.DevTools -> {
navigator.navigateToDeveloperSettings()
}
}
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
return@launch
}
is SlashCommand.SlashCommandSendMessage -> {
timelineController.invokeOnCurrentTimeline {
slashCommandService.proceedSendMessage(slashCommand, this)
.onFailure { cause ->
Timber.e(cause, "Failed to proceed with admin slash command")
slashCommandAction.value = AsyncAction.Failure(cause)
}
.onSuccess {
// Reset composer
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
}
}
return@launch
}
is SlashCommand.SlashCommandAdmin -> {
slashCommandAction.value = AsyncAction.Loading
slashCommandService.proceedAdmin(slashCommand)
.onFailure { cause ->
Timber.e(cause, "Failed to proceed with admin slash command")
slashCommandAction.value = AsyncAction.Failure(cause)
}
.onSuccess {
// Reset composer
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
slashCommandAction.value = AsyncAction.Uninitialized
}
return@launch
}
}
// Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {

View file

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

View file

@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -32,6 +33,7 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (MessageComposerEvent) -> Unit = {},
) = MessageComposerState(
textEditorState = textEditorState,
@ -43,5 +45,6 @@ fun aMessageComposerState(
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
slashCommandAction = slashCommandAction,
eventSink = eventSink,
)

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer
@ -115,6 +116,12 @@ internal fun MessageComposerView(
onTyping = ::onTyping,
onSelectRichContent = ::sendUri,
)
AsyncActionView(
async = state.slashCommandAction,
onSuccess = {},
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
)
}
@PreviewsDayNight

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -63,6 +65,7 @@ fun SuggestionsPickerView(
is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomId.value
is ResolvedSuggestion.Command -> suggestion.command.command
}
}
) {
@ -91,54 +94,81 @@ private fun SuggestionItemView(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.clickable { onSelectSuggestion(suggestion) },
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.clickable { onSelectSuggestion(suggestion) }
.padding(horizontal = 16.dp),
) {
val avatarSize = AvatarSize.Suggestion
val avatarData = when (suggestion) {
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
is ResolvedSuggestion.Command -> null
}
val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> AvatarType.Room()
is ResolvedSuggestion.Alias -> Room()
ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null
}
val title = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command
}
val details = when (suggestion) {
is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member,
is ResolvedSuggestion.Alias -> null
is ResolvedSuggestion.Command -> suggestion.command.parameters
}
val subtitle = when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
is ResolvedSuggestion.Command -> suggestion.command.description
}
if (avatarData != null && avatarType != null) {
Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp),
)
}
Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
.padding(top = 8.dp, bottom = 8.dp)
.align(Alignment.CenterVertically),
) {
title?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
title?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
details?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = subtitle,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
@ -174,7 +204,21 @@ internal fun SuggestionsPickerViewPreview() {
roomId = RoomId("!room:matrix.org"),
roomName = "My room",
roomAvatarUrl = null,
)
),
ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/noparam",
parameters = null,
description = "A slash command without parameters",
)
),
ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/withparam",
parameters = "<user-id> [reason]",
description = "A slash command with parameters",
)
),
),
onSelectSuggestion = {}
)

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -23,7 +24,9 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
*/
@Inject
class SuggestionsProcessor {
class SuggestionsProcessor(
private val slashCommandService: SlashCommandService,
) {
/**
* Process the suggestion.
* @param suggestion The current suggestion input
@ -31,6 +34,7 @@ class SuggestionsProcessor {
* @param roomAliasSuggestions The available room alias suggestions
* @param currentUserId The current user id
* @param canSendRoomMention Should return true if the current user can send room mentions
* @param isInThread Whether the composer is in a thread or not, used to filter slash commands suggestions
* @return The list of suggestions to display
*/
suspend fun process(
@ -39,6 +43,7 @@ class SuggestionsProcessor {
roomAliasSuggestions: List<RoomAliasSuggestion>,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
isInThread: Boolean,
): List<ResolvedSuggestion> {
suggestion ?: return emptyList()
return when (suggestion.type) {
@ -69,7 +74,16 @@ class SuggestionsProcessor {
)
}
}
SuggestionType.Command,
SuggestionType.Command -> {
// Command suggestions are valid only if this is the beginning of the message
if (suggestion.start == 0) {
slashCommandService.getSuggestions(suggestion.text, isInThread).map {
ResolvedSuggestion.Command(it)
}
} else {
emptyList()
}
}
SuggestionType.Emoji,
is SuggestionType.Custom -> {
// Clear suggestions

View file

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

View file

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

View file

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

View file

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

View file

@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.timeline.components.FloatingDateBadgeOverlay
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.di.LocalTimelineItemPresenterFactories
@ -105,6 +107,7 @@ fun TimelineView(
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState)
@ -210,6 +213,15 @@ fun TimelineView(
onJumpToLive = ::onJumpToLive,
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,
mode = DateFormatterMode.TimeOnly,
)
val sentDate = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender),
@ -108,6 +113,7 @@ class TimelineItemEventFactory(
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime,
sentDate = sentDate,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
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.TimelineItemTextBasedContent
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.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
@ -59,6 +60,12 @@ sealed interface TimelineItem {
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(
val id: UniqueId,
val model: TimelineItemVirtualModel
@ -75,6 +82,7 @@ sealed interface TimelineItem {
val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L,
val sentTime: String = "",
val sentDate: String = "",
val isMine: Boolean = false,
val isEditable: Boolean,
val canBeRepliedTo: Boolean,

View file

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

View file

@ -24,6 +24,8 @@ class FakeMessagesNavigator(
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
private val navigateToMemberLambda: (userId: UserId) -> Unit = { lambdaError() },
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val closeLambda: () -> Unit = { lambdaError() },
) : MessagesNavigator {
@ -51,10 +53,18 @@ class FakeMessagesNavigator(
onNavigateToRoomLambda(roomId, eventId, serverNames)
}
override fun navigateToMember(userId: UserId) {
navigateToMemberLambda(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
onOpenThreadLambda(threadRootId, focusedEventId)
}
override fun navigateToDeveloperSettings() {
navigateToDeveloperSettingsLambda()
}
override fun close() {
closeLambda()
}

View file

@ -0,0 +1,323 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MessageComposerPresenterSlashCommandTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val pickerProvider = FakePickerProvider().apply {
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
}
private val mediaPreProcessor = FakeMediaPreProcessor()
private val snackbarDispatcher = SnackbarDispatcher()
private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
private val analyticsService = FakeAnalyticsService()
private val notificationConversationService = FakeNotificationConversationService()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
}
}
@Test
fun `present - slash command error sets failure`() = runTest {
val presenter = createPresenter(
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.ErrorUnknownSlashCommand(A_FAILURE_REASON) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
val errorState = awaitItem()
assertThat(errorState.slashCommandAction.isFailure()).isTrue()
assertThat(errorState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
// Composer should not be reset when command is an error
assertThat(errorState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
// Close the error
errorState.eventSink(MessageComposerEvent.ClearSlashError)
val finalState = awaitItem()
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - slash command navigation ShowUser navigates to member and resets composer`() = runTest {
val navigateToMember = lambdaRecorder<UserId, Unit> {}
val navigator = FakeMessagesNavigator(navigateToMemberLambda = navigateToMember)
val presenter = createPresenter(
navigator = navigator,
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.ShowUser(A_USER_ID) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
advanceUntilIdle()
// navigation should be invoked and composer reset
navigateToMember.assertions().isCalledOnce().with(value(A_USER_ID))
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
}
}
@Test
fun `present - slash command navigation DevTools navigates to developer settings and resets composer`() = runTest {
val navigateToDev = lambdaRecorder<Unit> { }
val navigator = FakeMessagesNavigator(navigateToDeveloperSettingsLambda = navigateToDev)
val presenter = createPresenter(
navigator = navigator,
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.DevTools }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
advanceUntilIdle()
navigateToDev.assertions().isCalledOnce()
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
}
}
@Test
fun `present - slash command send message proceeds and resets composer`() = runTest {
val presenter = createPresenter(
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.SendPlainText(A_MESSAGE) },
proceedSendMessageResult = { _, _ -> Result.success(Unit) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
advanceUntilIdle()
// Composer reset after successful slash send
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
// Ensure no failure
assertThat(initialState.slashCommandAction.isFailure()).isFalse()
}
}
@Test
fun `present - slash command send message failure sets failure state`() = runTest {
val presenter = createPresenter(
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.SendPlainText("A_MESSAGE") },
proceedSendMessageResult = { _, _ -> Result.failure(Exception(A_FAILURE_REASON)) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
val failureState = awaitItem()
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
// Clear the error
failureState.eventSink(MessageComposerEvent.ClearSlashError)
val finalState = awaitItem()
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - slash command admin proceeds and resets state on success`() = runTest {
val presenter = createPresenter(
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
proceedAdminResult = { _ -> Result.success(Unit) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
val loadingState = awaitItem()
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
val successState = awaitItem()
// After success, state should be Uninitialized
assertThat(successState.slashCommandAction.isUninitialized()).isTrue()
assertThat(successState.textEditorState.messageHtml()).isEmpty()
}
}
@Test
fun `present - slash command admin proceeds and emit failure on error`() = runTest {
val presenter = createPresenter(
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
proceedAdminResult = { _ -> Result.failure(Exception(A_FAILURE_REASON)) }
)
)
presenter.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvent.SendMessage)
val loadingState = awaitItem()
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
val failureState = awaitItem()
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
// Clear error
failureState.eventSink(MessageComposerEvent.ClearSlashError)
val finalState = awaitItem()
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
}
}
private fun TestScope.createPresenter(
room: JoinedRoom = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) }
),
timeline: Timeline = room.liveTimeline,
navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this@MessageComposerPresenterSlashCommandTest.pickerProvider,
locationService: LocationService = FakeLocationService(true),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterSlashCommandTest.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterSlashCommandTest.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
permalinkParser = permalinkParser,
mentionSpanFormatter = FakeMentionSpanFormatter(),
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
),
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
isInThread: Boolean = false,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
isInThread = isInThread,
room = room,
mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD
)
}
)
},
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
locationService = locationService,
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline),
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
}

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId
@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -89,6 +91,9 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@ -144,6 +149,7 @@ class MessageComposerPresenterTest {
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@ -374,10 +380,13 @@ class MessageComposerPresenterTest {
val presenter = createPresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
typingNoticeResult = { Result.success(Unit) }
),
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
@ -409,10 +418,13 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled = false,
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
typingNoticeResult = { Result.success(Unit) }
),
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
@ -602,7 +614,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@ -633,7 +645,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda)
.isCalledOnce()
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false))
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false), value(MsgType.MSG_TYPE_TEXT))
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
@ -967,7 +979,12 @@ class MessageComposerPresenterTest {
)
givenRoomInfo(aRoomInfo(isDirect = false))
}
val presenter = createPresenter(room)
val presenter = createPresenter(
room = room,
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ -> emptyList() },
),
)
presenter.test {
val initialState = awaitItem()
@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit)
}
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest {
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(room = room)
val presenter = createPresenter(
room = room,
slashCommandService = FakeSlashCommandService(
parseResult = { _, _, _ -> SlashCommand.NotACommand }
),
)
presenter.test {
val initialState = awaitFirstItem()
@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest {
advanceUntilIdle()
sendMessageResult.assertions().isCalledOnce()
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))), value(MsgType.MSG_TYPE_TEXT), value(false))
// Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda)
.isCalledOnce()
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false), value(MsgType.MSG_TYPE_TEXT))
// Check intentional mentions on edit message
skipItems(1)
@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
isInThread: Boolean = false,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
isInThread = isInThread,
room = room,
mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore,
@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest {
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(),
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled

View file

@ -17,6 +17,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -27,10 +29,13 @@ import org.junit.Test
class SuggestionsProcessorTest {
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "")
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
private val suggestionsProcessor = SuggestionsProcessor()
private val suggestionsProcessor = SuggestionsProcessor(
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ -> emptyList() },
),
)
@Test
fun `processing null suggestion will return empty suggestion`() = runTest {
@ -40,18 +45,59 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@Test
fun `processing Command will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aCommandSuggestion,
fun `processing Command will return suggestions from the slash service`() = runTest {
val suggestionsProcessorWithCommand = SuggestionsProcessor(
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ ->
listOf(
SlashCommandSuggestion(
command = "aCommand",
parameters = null,
description = "A description",
),
)
},
),
)
val result = suggestionsProcessorWithCommand.process(
suggestion = Suggestion(0, 1, SuggestionType.Command, ""),
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isNotEmpty()
}
@Test
fun `processing Command will return empty list if start of suggestion is not 0`() = runTest {
val suggestionsProcessorWithCommand = SuggestionsProcessor(
slashCommandService = FakeSlashCommandService(
getSuggestionsResult = { _, _ ->
listOf(
SlashCommandSuggestion(
command = "aCommand",
parameters = null,
description = "A description",
),
)
},
),
)
val result = suggestionsProcessorWithCommand.process(
suggestion = Suggestion(1, 2, SuggestionType.Command, ""),
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -64,6 +110,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -76,6 +123,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -88,6 +136,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -100,6 +149,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -120,6 +170,7 @@ class SuggestionsProcessorTest {
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -149,6 +200,7 @@ class SuggestionsProcessorTest {
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -178,6 +230,7 @@ class SuggestionsProcessorTest {
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -198,6 +251,7 @@ class SuggestionsProcessorTest {
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -227,6 +281,7 @@ class SuggestionsProcessorTest {
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -240,6 +295,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -257,6 +313,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = UserId("@alice:server.org"),
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -270,6 +327,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -283,6 +341,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEmpty()
}
@ -296,6 +355,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -313,6 +373,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
@ -331,6 +392,7 @@ class SuggestionsProcessorTest {
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { false },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(

View file

@ -12,6 +12,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
@ -154,10 +155,10 @@ class TimelineControllerTest {
@Test
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention> ->
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val liveTimeline = FakeTimeline(name = "live").apply {

View file

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

View file

@ -34,4 +34,5 @@ internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings
}

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,17 @@
<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_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_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_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_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_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_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>
@ -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_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_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_title">"Kan du ikke bekræfte? Du skal nulstille din identitet."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slå fra"</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_title">"Er du sikker på, at du vil slå sikkerhedskopiering fra?"</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_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 er nødt til at nulstille din digitale identitet."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slet"</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 slette nøglelageret?"</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_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>
@ -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_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_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_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_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>
</resources>

View file

@ -12,6 +12,7 @@
<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_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_2">"Войдите в свой аккаунт еще раз"</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_footer">"Сбрасывайте личность только в том случае, если у вас нет доступа к другим устройству, на которых выполнен вход, и вы потеряли ключ восстановления."</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_title">"Вы действительно хотите отключить резервное копирование?"</string>
<string name="screen_key_backup_disable_description">"Удаление хранилища ключей приведёт к удалению вашей криптографической личности и ключей сообщений с сервера, а также отключению следующих функций безопасности:"</string>

View file

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

View file

@ -2,8 +2,8 @@
<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_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_title">"Bekræft din identitet"</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 digitale identitet"</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_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_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_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_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>
@ -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_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_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_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_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_signout_in_progress_dialog_content">"Logger ud…"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources>

View file

@ -11,7 +11,7 @@ ksp = "2.3.6"
firebaseAppDistribution = "5.2.1"
# AndroidX
core = "1.17.0"
core = "1.18.0"
datastore = "1.2.1"
constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
@ -19,10 +19,10 @@ lifecycle = "2.10.0"
activity = "1.13.0"
media3 = "1.9.3"
camera = "1.5.3"
work = "2.11.1"
work = "2.11.2"
# Compose
compose_bom = "2026.03.00"
compose_bom = "2026.03.01"
# Coroutines
coroutines = "1.10.2"
@ -54,7 +54,7 @@ haze = "1.7.2"
dependencyAnalysis = "3.6.1"
# DI
metro = "0.11.4"
metro = "0.12.0"
# Auto service
autoservice = "1.1.1"
@ -64,7 +64,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0"
kover = "0.9.7"
kover = "0.9.8"
[libraries]
# Project
@ -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" }
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" }
google_tink = "com.google.crypto.tink:tink-android:1.20.0"
google_tink = "com.google.crypto.tink:tink-android:1.21.0"
# AndroidX
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_recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
androidx_browser = "androidx.browser:browser:1.9.0"
androidx_browser = "androidx.browser:browser:1.10.0"
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_splash = "androidx.core:core-splashscreen:1.2.0"
@ -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
# 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.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.24"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.03.31"
# Others
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-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:sqlcipher-android:4.13.0"
sqlcipher = "net.zetetic:sqlcipher-android:4.14.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.6.2"
unifiedpush = "org.unifiedpush.android:connector:3.3.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@ -220,13 +220,13 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref =
color_picker = "io.mhssn:colorpicker:1.0.0"
# 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"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.33.2"
# 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"
# Di

View file

@ -16,9 +16,11 @@ import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
fun View.hideKeyboard() {
val imm = context?.getSystemService<InputMethodManager>()
@ -26,29 +28,39 @@ fun View.hideKeyboard() {
}
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()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val imm = context?.getSystemService<InputMethodManager>() ?: return
val future = CompletableDeferred<Unit>()
val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { view, insets ->
if (!insets.isVisible(WindowInsets.Type.ime())) {
mutex.unlock()
future.complete(Unit)
// Remove the listener now, it's a single use operation
setOnApplyWindowInsetsListener(null)
}
insets
}
imm?.hideSoftInputFromWindow(windowToken, 0)
imm.hideSoftInputFromWindow(windowToken, 0)
} else {
@Suppress("DEPRECATION")
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
imm.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN ||
resultCode == InputMethodManager.RESULT_HIDDEN) {
mutex.unlock()
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || resultCode == InputMethodManager.RESULT_HIDDEN) {
future.complete(Unit)
}
}
})
}
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) {

View file

@ -16,7 +16,10 @@ import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable
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.BoxScope
import androidx.compose.foundation.layout.Column
@ -41,10 +44,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
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.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
.run {
if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) {
detectVerticalDragGestures(
customDetectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
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 {
@ -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)
@Composable
@Suppress("UnusedPrivateMember")

View file

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

View file

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

View file

@ -163,10 +163,24 @@ enum class FeatureFlags(
),
ValidateNetworkWhenSchedulingNotificationFetching(
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. " +
"Enabling this can be problematic in air-gapped environments.",
defaultValue = { true },
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 Alias -> roomAlias.value
}
companion object {
fun from(id: String): RoomIdOrAlias? {
return when {
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
else -> null
}
}
}
}
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)

View file

@ -17,3 +17,18 @@ interface MxcTools {
*/
fun mxcUri2FilePath(mxcUri: String): String?
}
/**
* "mxc" scheme, including "://". So "mxc://".
*/
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
/**
* Return true if the String starts with "mxc://".
*/
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
/**
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
*/
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.timeline
enum class MsgType {
MSG_TYPE_TEXT,
MSG_TYPE_EMOTE,
// For future support
MSG_TYPE_SNOW,
// For future support
MSG_TYPE_CONFETTI,
}

View file

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

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
@ -271,8 +272,16 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
asPlainText = asPlainText,
).use { content ->
runCatchingExceptions<Unit> {
inner.send(content)
}
@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
)
inner.sendReply(
msg = msg,
eventId = repliedToEventId.value,

View file

@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.MessageContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
/**
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(intentionalMentions.map())
fun from(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): RoomMessageEventContentWithoutRelation {
return when {
asPlainText -> contentWithoutRelationFromMessage(
MessageContent(
msgType = MessageType.Text(
TextMessageContent(
body = body,
formatted = null,
)
),
body = body,
isEdited = false,
mentions = null,
)
)
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromHtmlAsEmote(body, htmlBody)
} else {
messageEventContentFromHtml(body, htmlBody)
}
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromMarkdownAsEmote(body)
} else {
messageEventContentFromMarkdown(body)
}
}
.withMentions(intentionalMentions.map())
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -64,7 +65,9 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError()
}
@ -76,8 +79,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions)
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError()
}
@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
msgType,
)
var sendImageLambda: (

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
mutableStateOf(null)
}
val isTargetVisible = LocalNodeTargetVisibility.current
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
exoPlayer.pause()
}
}
LaunchedEffect(isTargetVisible) {
if (!isTargetVisible) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
}
} else {
exoPlayer.setMediaItems(emptyList())
LaunchedEffect(Unit) {
exoPlayer.setMediaItems(emptyList())
}
}
val context = LocalContext.current
val waveform = info?.waveform
@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
}
},
update = { playerView ->
playerView.isVisible = metadata.hasArtwork()
playerView.isVisible = metadata.hasArtwork() && isTargetVisible
},
onRelease = { playerView ->
playerView.player = null
@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
DisposableEffect(exoPlayer) {
exoPlayer.addListener(playerListener)
onDispose {
if (!exoPlayer.isReleased) {
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.push.api.push.PushHandlingWakeLock
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
@ContributesBinding(AppScope::class)
@ -22,24 +21,13 @@ import kotlin.time.Duration
class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context,
) : PushHandlingWakeLock {
private val count = AtomicInteger(0)
override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context)
count.incrementAndGet()
}
override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context)
if (count.decrementAndGet() <= 0) {
Timber.d("No more wakelock needed for push handling, stopping service.")
count.set(0)
} else {
Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.")
FetchPushForegroundService.startIfNeeded(context)
}
}
}

View file

@ -17,6 +17,7 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject
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.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
@ -57,6 +58,8 @@ class FetchPushForegroundService : Service() {
}
}
private var isOnForeground = false
override fun onCreate() {
Timber.d("Creating FetchPushForegroundService")
@ -71,12 +74,32 @@ class FetchPushForegroundService : Service() {
.setVibrate(longArrayOf(0))
.setSound(null)
.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()
}
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)
// 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 {
wakelock.release()
if (isOnForeground) {
wakelock.release()
stopForeground(STOP_FOREGROUND_REMOVE)
}
stopForeground(STOP_FOREGROUND_REMOVE)
return super.stopService(intent)
}
override fun onTimeout(startId: Int) {
super.onTimeout(startId)
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
}
}
companion object {
@ -119,7 +145,13 @@ class FetchPushForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
runCatchingExceptions { context.startForegroundService(intent) }
.onFailure { throwable ->
Timber.e(
throwable,
"Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
)
}
} else {
context.startService(intent)
}

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest {
advanceUntilIdle()
sendMessage.assertions()
.isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
.with(
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
value(MsgType.MSG_TYPE_TEXT),
value(false),
)
onNotifiableEventsReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply blank message`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
}
@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply to thread`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
value(true)
value(true),
value(MsgType.MSG_TYPE_TEXT),
)
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.pushproviders.firebase
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.firebase.messaging.RemoteMessage.PRIORITY_HIGH
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
@ -45,8 +46,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
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
pushHandlingWakeLock.lock()
val isHighPriority = message.priority == PRIORITY_HIGH
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 {
val pushData = pushParser.parse(message.data)
@ -58,7 +62,9 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
"$it: ${message.data[it]}"
},
)
pushHandlingWakeLock.unlock()
if (isHighPriority) {
pushHandlingWakeLock.unlock()
}
} else {
val handled = pushHandler.handle(
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 (!handled) {
if (!handled && isHighPriority) {
pushHandlingWakeLock.unlock()
}
}

View file

@ -96,6 +96,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "high")
},
)
)
@ -127,6 +128,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "high")
},
)
)
@ -141,6 +143,33 @@ class VectorFirebaseMessagingServiceTest {
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
fun `test new token is forwarded to the handler`() = runTest {
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