Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
f19295d63d
291 changed files with 4973 additions and 1595 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/build_enterprise.yml
vendored
2
.github/workflows/build_enterprise.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/generate_github_pages.yml
vendored
4
.github/workflows/generate_github_pages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/maestro-local.yml
vendored
2
.github/workflows/maestro-local.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/nightlyReports.yml
vendored
4
.github/workflows/nightlyReports.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/quality.yml
vendored
12
.github/workflows/quality.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/sonar.yml
vendored
2
.github/workflows/sonar.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/sync-localazy.yml
vendored
2
.github/workflows/sync-localazy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -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' }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm your identity"
|
||||
visible: "Confirm your digital identity"
|
||||
timeout: 60000
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
CHANGES.md
46
CHANGES.md
|
|
@ -1,3 +1,49 @@
|
|||
Changes in Element X v26.04.0
|
||||
=============================
|
||||
|
||||
## What's Changed
|
||||
### ✨ Features
|
||||
* Add floating/sticky date badge in the timeline by @kalix127 in https://github.com/element-hq/element-x-android/pull/6496
|
||||
### 🐛 Bugfixes
|
||||
* Fix `ForegroundServiceDidNotStartInTimeException` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6470
|
||||
* Fix media cover placeholder floating by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6484
|
||||
* Try handling `ForegroundServiceStartNotAllowedException` better by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6483
|
||||
* Fix crash when using `View.hideKeyboardAndAwaitAnimation` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6502
|
||||
* Fix content scrolling not working in the RTE by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6492
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6486
|
||||
### 🧱 Build
|
||||
* Add instructions for AI by @bmarty in https://github.com/element-hq/element-x-android/pull/6468
|
||||
* Fix permissions to publish GitHub pages. by @bmarty in https://github.com/element-hq/element-x-android/pull/6500
|
||||
* Try fixing location pin previews by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6495
|
||||
* CI: yet another Maestro fix by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6505
|
||||
### 📄 Documentation
|
||||
* Add some instructions for features to the community PR notice message by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6465
|
||||
### 🚧 In development 🚧
|
||||
* Setup live location sharing feature by @ganfra in https://github.com/element-hq/element-x-android/pull/6342
|
||||
### Dependency upgrades
|
||||
* Update dependency io.sentry:sentry-android to v8.36.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6461
|
||||
* Update metro to v0.11.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6448
|
||||
* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v8.0.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/6459
|
||||
* Update sqldelight to v2.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6449
|
||||
* Update nschloe/action-cached-lfs-checkout action to v1.2.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6442
|
||||
* Update kotlin to v2.3.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6437
|
||||
* Update dependency io.element.android:element-call-embedded to v0.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6358
|
||||
* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6474
|
||||
* fix(deps): update dependency com.google.firebase:firebase-bom to v34.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6478
|
||||
* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6487
|
||||
* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6499
|
||||
* fix(deps): update dependency com.posthog:posthog-android to v3.39.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6504
|
||||
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.03.31 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6494
|
||||
### Others
|
||||
* Iterate on space header by @bmarty in https://github.com/element-hq/element-x-android/pull/6456
|
||||
* Add margin after bullet points by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6446
|
||||
* chore: update the build-rust-sdk script by @bnjbvr in https://github.com/element-hq/element-x-android/pull/6476
|
||||
* Update replied message UI by @bmarty in https://github.com/element-hq/element-x-android/pull/6472
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.03.4...v26.04.0
|
||||
|
||||
Changes in Element X v26.03.4
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202604000.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202604000.txt
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
|||
|
||||
@Parcelize
|
||||
data object NotificationTroubleshoot : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object DeveloperSettings : InitialTarget
|
||||
}
|
||||
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,11 @@ class PreferencesFlowNode(
|
|||
}
|
||||
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
if (backstack.canPop()) {
|
||||
backstack.pop()
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> { }
|
||||
|
|
|
|||
17
libraries/slashcommands/api/build.gradle.kts
Normal file
17
libraries/slashcommands/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
35
libraries/slashcommands/impl/build.gradle.kts
Normal file
35
libraries/slashcommands/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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 -> "( ͡° ͜ʖ ͡°)"
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue