diff --git a/.github/renovate.json b/.github/renovate.json
index 66e1b98aea..ca74a436e8 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -7,7 +7,8 @@
"PR-Dependencies"
],
"ignoreDeps" : [
- "string:app_name"
+ "string:app_name",
+ "gradle"
],
"packageRules" : [
{
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 92380492a2..9f039ce5b9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,7 +36,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay APK
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 602b43a7e1..737b61ee06 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -44,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 46494ba76a..c86c5799e3 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -19,7 +19,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index d35bace705..975902e708 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -1,18 +1,26 @@
name: Update Gradle Wrapper
on:
+ workflow_dispatch:
schedule:
- cron: "0 0 * * *"
jobs:
update-gradle-wrapper:
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' }}
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ name: Use JDK 17
+ if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '17'
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
- # Skip in forks
- if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
+ repo-token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
target-branch: develop
+ labels: PR-Build
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
deleted file mode 100644
index 3c619328ae..0000000000
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: "Validate Gradle Wrapper"
-on:
- pull_request:
- merge_group:
- push:
- branches: [ main, develop ]
-
-jobs:
- validation:
- name: "Validation"
- runs-on: ubuntu-latest
- # No concurrency required, this is a prerequisite to other actions and should run every time.
- steps:
- - uses: actions/checkout@v4
- - uses: gradle/wrapper-validation-action@v3
diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index eb39516e5e..fd2cc75fd0 100644
--- a/.github/workflows/maestro.yml
+++ b/.github/workflows/maestro.yml
@@ -39,7 +39,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 94f1ab275d..a0d39068eb 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -27,7 +27,7 @@ jobs:
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: false
@@ -67,7 +67,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index a0643c426f..5157a4b3d6 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -90,7 +90,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
@@ -130,7 +130,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -174,7 +174,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
@@ -214,7 +214,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
@@ -254,7 +254,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Knit
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
index 44522a0084..e176c5d7bc 100644
--- a/.github/workflows/recordScreenshots.yml
+++ b/.github/workflows/recordScreenshots.yml
@@ -39,7 +39,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 38be804fc1..fea2b6715c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -89,7 +89,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index bbab77cf65..b5e0be7e2d 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -33,7 +33,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 5f99cac17a..1330b373bf 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -18,7 +18,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index fa274baf89..e96dcae925 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 148fdd2469..4cb7457249 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CHANGES.md b/CHANGES.md
index c2c2641b34..2661597461 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,80 @@
+Changes in Element X v0.5.0 (2024-07-24)
+=========================================
+
+### 🙌 Improvements
+* Add icon for "Mark as read" and "Mark as unread" actions. by @bmarty in https://github.com/element-hq/element-x-android/pull/3144
+* Add support for Picture In Picture for Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/3159
+* Set pin grace period to 2 minutes by @bmarty in https://github.com/element-hq/element-x-android/pull/3172
+* Unify the way we decide whether a room is a DM or a group room by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3100
+* Subscribe to `RoomListItems` in the visible range by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3169
+* Improve pip and add feature flag. by @bmarty in https://github.com/element-hq/element-x-android/pull/3199
+* Open Source licenses: add color for links. by @bmarty in https://github.com/element-hq/element-x-android/pull/3215
+* Cancel ringing call notification on call cancellation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3047
+
+### 🐛 Bugfixes
+* Fix `MainActionButton` layout for long texts by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3158
+* Always follow the desired theme for Pin, Incoming Call and Element Call screens by @bmarty in https://github.com/element-hq/element-x-android/pull/3165
+* Fix empty screen issue after clearing the cache by @bmarty in https://github.com/element-hq/element-x-android/pull/3163
+* Restore intentional mentions in the markdown/plain text editor by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3193
+* Fix crash in the room list after a forced log out in background by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3180
+* Clear existing notification when a room is marked as read by @bmarty in https://github.com/element-hq/element-x-android/pull/3203
+* Fix crash when Pin code screen is displayed by @bmarty in https://github.com/element-hq/element-x-android/pull/3205
+* Fix pillification not working for non formatted message bodies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3201
+* Update grammar on Matrix Ids to be more spec compliant and render error instead of infinite loading in room member list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3206
+* Reduce the risk of text truncation in buttons. by @bmarty in https://github.com/element-hq/element-x-android/pull/3209
+* Ensure that the manual dark theme is rendering correctly regarding -night resource and keyboard by @bmarty in https://github.com/element-hq/element-x-android/pull/3216
+* Fix rendering issue of SunsetPage in dark mode by @bmarty in https://github.com/element-hq/element-x-android/pull/3217
+* Fix linkification not working for `Spanned` strings in text messages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3233
+* Edit : fallback to room.edit when timeline item is not found. by @ganfra in https://github.com/element-hq/element-x-android/pull/3239
+
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3156
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3192
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3232
+
+### 🧱 Build
+* Remove Showkase processor not found warning from Danger by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3148
+* Set targetSDK to 34 by @bmarty in https://github.com/element-hq/element-x-android/pull/3149
+* Add a local copy of `inplace-fix.py` and `fix-pg-map-id.py` by @bmarty in https://github.com/element-hq/element-x-android/pull/3167
+* Only add private SSH keys and clone submodules in the original repo by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3225
+* Fix CI for forks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3226
+
+### Dependency upgrades
+* Update dependency io.element.android:compound-android to v0.0.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3143
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.31 by @renovate in https://github.com/element-hq/element-x-android/pull/3145
+* Update dependency com.squareup:kotlinpoet to v1.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3150
+* Update dependency org.robolectric:robolectric to v4.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3157
+* Update plugin dependencycheck to v10.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3154
+* Update wysiwyg to v2.37.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3162
+* Update plugin sonarqube to v5.1.0.4882 by @renovate in https://github.com/element-hq/element-x-android/pull/3139
+* Update dependency org.jsoup:jsoup to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3171
+* Update dependency com.google.firebase:firebase-bom to v33.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3178
+* Update telephoto to v0.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3191
+* Update dependency com.google.truth:truth to v1.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3187
+* Update dependency com.squareup:kotlinpoet to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3194
+* Update dependency io.mockk:mockk to v1.13.12 by @renovate in https://github.com/element-hq/element-x-android/pull/3198
+* Update dependency io.sentry:sentry-android to v7.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3200
+* Update plugin dependencycheck to v10.0.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3204
+* Update dependency gradle to v8.9 by @renovate in https://github.com/element-hq/element-x-android/pull/3177
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.32 by @renovate in https://github.com/element-hq/element-x-android/pull/3202
+* Update coil to v2.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3210
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.33 by @renovate in https://github.com/element-hq/element-x-android/pull/3220
+* Update wysiwyg to v2.37.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3218
+* Update telephoto to v0.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3230
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.34 by @renovate in https://github.com/element-hq/element-x-android/pull/3237
+
+### Others
+* Reduce delay when selecting room list filters by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3160
+* Add `--alignment-preserved true` when signing APK for F-Droid. by @bmarty in https://github.com/element-hq/element-x-android/pull/3161
+* Ensure that all callback plugins are invoked. by @bmarty in https://github.com/element-hq/element-x-android/pull/3146
+* Add generated screen to show open source licenses in Gplay variant by @bmarty in https://github.com/element-hq/element-x-android/pull/3207
+* Performance : improve time to open a room. by @ganfra in https://github.com/element-hq/element-x-android/pull/3186
+* Add logging to help debug forced logout issues by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3208
+* Use the right filename for log files so they're sorted in rageshakes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3219
+* Compose : add immutability to some Reaction classes by @ganfra in https://github.com/element-hq/element-x-android/pull/3224
+* Fix stickers display text on room summary by @surakin in https://github.com/element-hq/element-x-android/pull/3221
+* Rework FakeMatrixRoom so that it contains only lambdas. by @bmarty in https://github.com/element-hq/element-x-android/pull/3229
+
Changes in Element X v0.4.16 (2024-07-05)
=========================================
diff --git a/README.md b/README.md
index 35d4cffb27..25f2f2c835 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
[](https://github.com/element-hq/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop)
-[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
-[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
-[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
+[](https://sonarcloud.io/summary/new_code?id=element-x-android)
+[](https://sonarcloud.io/summary/new_code?id=element-x-android)
+[](https://sonarcloud.io/summary/new_code?id=element-x-android)
[](https://codecov.io/github/vector-im/element-x-android)
[](https://matrix.to/#/#element-x-android:matrix.org)
[](https://localazy.com/p/element)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index c800b9d996..89dcab5fad 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -40,3 +40,5 @@
-keepclassmembers class android.view.JavaViewSpy {
static int windowAttachCount(android.view.View);
}
+
+-keep class io.element.android.x.di.** { *; }
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index fa0e1e2199..ad67e6746a 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -13,6 +13,7 @@
+
@@ -21,6 +22,7 @@
+
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
index 2ad2916550..2bbb96cabb 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
@@ -31,7 +31,6 @@ object TimelineConfig {
StateEventType.ROOM_GUEST_ACCESS,
StateEventType.ROOM_HISTORY_VISIBILITY,
StateEventType.ROOM_JOIN_RULES,
- StateEventType.ROOM_PINNED_EVENTS,
StateEventType.ROOM_POWER_LEVELS,
StateEventType.ROOM_SERVER_ACL,
StateEventType.ROOM_TOMBSTONE,
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 53680fd44e..887f48e203 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.oidc.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
@@ -66,6 +67,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index d200acb84e..87e08b6bb9 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -42,8 +42,6 @@ import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
-import io.element.android.features.login.api.oidc.OidcAction
-import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
@@ -58,6 +56,8 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index d41fbbfebb..73ad6f6008 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -17,12 +17,12 @@
package io.element.android.appnav.intent
import android.content.Intent
-import io.element.android.features.login.api.oidc.OidcAction
-import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.api.OidcIntentResolver
import timber.log.Timber
import javax.inject.Inject
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index aa0fe9cbf1..8134c091f6 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -21,9 +21,6 @@ import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.login.api.oidc.OidcAction
-import io.element.android.features.login.impl.oidc.DefaultOidcIntentResolver
-import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
@@ -33,6 +30,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.impl.DefaultOidcIntentResolver
+import io.element.android.libraries.oidc.impl.OidcUrlParser
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Assert.assertThrows
import org.junit.Test
diff --git a/build.gradle.kts b/build.gradle.kts
index 8139ed6eb8..97daebb1d3 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -61,7 +61,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.4.5")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.4.10")
}
// KtLint
@@ -129,11 +129,11 @@ dependencyAnalysis {
// To run a sonar analysis:
// Run './gradlew sonar -Dsonar.login='
// The SONAR_LOGIN is stored in passbolt as Token Sonar Cloud Bma
-// Sonar result can be found here: https://sonarcloud.io/project/overview?id=vector-im_element-x-android
+// Sonar result can be found here: https://sonarcloud.io/project/overview?id=element-x-android
sonar {
properties {
property("sonar.projectName", "element-x-android")
- property("sonar.projectKey", "vector-im_element-x-android")
+ property("sonar.projectKey", "element-x-android")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.projectVersion", "1.0") // TODO project(":app").android.defaultConfig.versionName)
property("sonar.sourceEncoding", "UTF-8")
@@ -141,7 +141,7 @@ sonar {
property("sonar.links.ci", "https://github.com/element-hq/element-x-android/actions")
property("sonar.links.scm", "https://github.com/element-hq/element-x-android/")
property("sonar.links.issue", "https://github.com/element-hq/element-x-android/issues")
- property("sonar.organization", "new_vector_ltd_organization")
+ property("sonar.organization", "element-hq")
property("sonar.login", if (project.hasProperty("SONAR_LOGIN")) project.property("SONAR_LOGIN")!! else "invalid")
// exclude source code from analyses separated by a colon (:)
diff --git a/fastlane/metadata/android/en-US/changelogs/40005010.txt b/fastlane/metadata/android/en-US/changelogs/40005010.txt
new file mode 100644
index 0000000000..3c6c01983c
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40005010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Element Call improvements and bug fixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/analytics/api/src/main/res/values-nl/translations.xml b/features/analytics/api/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..fcd05769c5
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-nl/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Deel anonieme gebruiksgegevens om ons te helpen problemen te identificeren."
+ "Je kunt al onze voorwaarden %1$s lezen."
+ "hier"
+
diff --git a/features/analytics/api/src/main/res/values-pl/translations.xml b/features/analytics/api/src/main/res/values-pl/translations.xml
index 26aa9c6073..a3347582cc 100644
--- a/features/analytics/api/src/main/res/values-pl/translations.xml
+++ b/features/analytics/api/src/main/res/values-pl/translations.xml
@@ -1,7 +1,7 @@
- "Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."
- "Możesz przeczytać wszystkie nasze warunki %1$s."
+ "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."
+ "Przeczytaj nasze warunki użytkowania %1$s."
"tutaj"
"Udostępniaj dane analityczne"
diff --git a/features/analytics/api/src/main/res/values-uz/translations.xml b/features/analytics/api/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..787a1b03bc
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-uz/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring."
+ "Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s."
+ "Bu yerga"
+ "Analitik ma\'lumotlarni ulashish"
+
diff --git a/features/analytics/impl/src/main/res/values-nl/translations.xml b/features/analytics/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..dc45280bfe
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "We zullen geen persoonlijke gegevens registreren of er een profiel van maken"
+ "Deel anonieme gebruiksgegevens om ons te helpen problemen te identificeren."
+ "Je kunt al onze voorwaarden %1$s lezen."
+ "hier"
+ "Je kunt dit op elk moment uitschakelen"
+ "We delen je gegevens niet met derden"
+ "Help %1$s te verbeteren"
+
diff --git a/features/analytics/impl/src/main/res/values-pl/translations.xml b/features/analytics/impl/src/main/res/values-pl/translations.xml
index 99e3e441bb..61da8408ad 100644
--- a/features/analytics/impl/src/main/res/values-pl/translations.xml
+++ b/features/analytics/impl/src/main/res/values-pl/translations.xml
@@ -1,10 +1,10 @@
"Nie będziemy rejestrować ani profilować żadnych danych osobistych"
- "Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."
- "Możesz przeczytać wszystkie nasze warunki %1$s."
+ "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."
+ "Przeczytaj nasze warunki użytkowania %1$s."
"tutaj"
"Możesz to wyłączyć w dowolnym momencie"
- "Nie będziemy udostępniać Twoich danych podmiotom trzecim"
+ "Nie będziemy udostępniać Twoich danych stronom trzecim"
"Pomóż nam ulepszyć %1$s"
diff --git a/features/analytics/impl/src/main/res/values-uz/translations.xml b/features/analytics/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..daa1080628
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Biz hech qanday shaxsiy ma\'lumotlarni yozmaymiz yoki profilga kiritmaymiz"
+ "Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring."
+ "Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s."
+ "Bu yerga"
+ "Buni istalgan vaqtda oʻchirib qoʻyishingiz mumkin"
+ "Biz sizning ma\'lumotlaringizni uchinchi tomonlar bilan baham ko\'rmaymiz"
+ "Yaxshilashga yordam bering%1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-zh/translations.xml b/features/analytics/impl/src/main/res/values-zh/translations.xml
index 887930eac7..d18650654b 100644
--- a/features/analytics/impl/src/main/res/values-zh/translations.xml
+++ b/features/analytics/impl/src/main/res/values-zh/translations.xml
@@ -4,7 +4,7 @@
"共享匿名使用数据以帮助我们排查问题。"
"您可以阅读我们的所有条款 %1$s。"
"此处"
- "你可以随时关闭此功能"
+ "可以随时关闭此功能"
"我们不会与第三方共享您的数据"
"帮助改进 %1$s"
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
index da3c08da32..4d29f757ea 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
@@ -16,6 +16,10 @@
package io.element.android.features.call.impl.pip
+import io.element.android.features.call.impl.utils.PipController
+
sealed interface PictureInPictureEvents {
+ data class SetPipController(val pipController: PipController) : PictureInPictureEvents
data object EnterPictureInPicture : PictureInPictureEvents
+ data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
index 2c974382d0..ab0b8f49b9 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
@@ -16,17 +16,17 @@
package io.element.android.features.call.impl.pip
-import android.app.Activity
-import android.app.PictureInPictureParams
-import android.os.Build
-import android.util.Rational
-import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.features.call.impl.utils.PipController
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag
+import kotlinx.coroutines.launch
import timber.log.Timber
-import java.lang.ref.WeakReference
import javax.inject.Inject
private val loggerTag = LoggerTag("PiP")
@@ -35,71 +35,69 @@ class PictureInPicturePresenter @Inject constructor(
pipSupportProvider: PipSupportProvider,
) : Presenter {
private val isPipSupported = pipSupportProvider.isPipSupported()
- private var isInPictureInPicture = mutableStateOf(false)
- private var hostActivity: WeakReference? = null
+ private var pipView: PipView? = null
@Composable
override fun present(): PictureInPictureState {
+ val coroutineScope = rememberCoroutineScope()
+ var isInPictureInPicture by remember { mutableStateOf(false) }
+ var pipController by remember { mutableStateOf(null) }
+
fun handleEvent(event: PictureInPictureEvents) {
when (event) {
- PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
+ is PictureInPictureEvents.SetPipController -> {
+ pipController = event.pipController
+ }
+ PictureInPictureEvents.EnterPictureInPicture -> {
+ coroutineScope.launch {
+ switchToPip(pipController)
+ }
+ }
+ is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
+ Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
+ isInPictureInPicture = event.isInPip
+ if (event.isInPip) {
+ pipController?.enterPip()
+ } else {
+ pipController?.exitPip()
+ }
+ }
}
}
return PictureInPictureState(
supportPip = isPipSupported,
- isInPictureInPicture = isInPictureInPicture.value,
+ isInPictureInPicture = isInPictureInPicture,
eventSink = ::handleEvent,
)
}
- fun onCreate(activity: Activity) {
+ fun setPipView(pipView: PipView?) {
if (isPipSupported) {
- Timber.tag(loggerTag.value).d("onCreate: Setting PiP params")
- hostActivity = WeakReference(activity)
- hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
+ Timber.tag(loggerTag.value).d("Setting PiP params")
+ this.pipView = pipView
+ pipView?.setPipParams()
} else {
- Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
+ Timber.tag(loggerTag.value).d("setPipView: PiP is not supported")
}
}
- fun onDestroy() {
- Timber.tag(loggerTag.value).d("onDestroy")
- hostActivity?.clear()
- hostActivity = null
- }
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun getPictureInPictureParams(): PictureInPictureParams {
- return PictureInPictureParams.Builder()
- // Portrait for calls seems more appropriate
- .setAspectRatio(Rational(3, 5))
- .apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- setAutoEnterEnabled(true)
- }
- }
- .build()
- }
-
/**
- * Enters Picture-in-Picture mode.
+ * Enters Picture-in-Picture mode, if allowed by Element Call.
*/
- private fun switchToPip() {
+ private suspend fun switchToPip(pipController: PipController?) {
if (isPipSupported) {
- Timber.tag(loggerTag.value).d("Switch to PiP mode")
- hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams())
- ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
+ if (pipController == null) {
+ Timber.tag(loggerTag.value).w("webPipApi is not available")
+ }
+ if (pipController == null || pipController.canEnterPip()) {
+ Timber.tag(loggerTag.value).d("Switch to PiP mode")
+ pipView?.enterPipMode()
+ ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
+ } else {
+ Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call")
+ pipView?.hangUp()
+ }
}
}
-
- fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
- Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode")
- isInPictureInPicture.value = isInPictureInPictureMode
- }
-
- fun onUserLeaveHint() {
- Timber.tag(loggerTag.value).d("onUserLeaveHint")
- switchToPip()
- }
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt
index 6cf3f080ad..3762174481 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt
@@ -24,9 +24,6 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import kotlinx.coroutines.runBlocking
import javax.inject.Inject
interface PipSupportProvider {
@@ -37,15 +34,10 @@ interface PipSupportProvider {
@ContributesBinding(AppScope::class)
class DefaultPipSupportProvider @Inject constructor(
@ApplicationContext private val context: Context,
- private val featureFlagService: FeatureFlagService,
) : PipSupportProvider {
override fun isPipSupported(): Boolean {
val isSupportedByTheOs = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE).orFalse()
- return if (isSupportedByTheOs) {
- runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PictureInPicture) }
- } else {
- false
- }
+ return isSupportedByTheOs
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
similarity index 81%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt
rename to features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
index f4c91aece6..998d36da5c 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
@@ -14,8 +14,10 @@
* limitations under the License.
*/
-package io.element.android.features.lockscreen.impl.unlock.signout
+package io.element.android.features.call.impl.pip
-interface SignOut {
- suspend operator fun invoke(): String?
+interface PipView {
+ fun setPipParams()
+ fun enterPipMode(): Boolean
+ fun hangUp()
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index 70ab2e30c2..5c3d02b8ac 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -40,6 +40,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
import io.element.android.features.call.impl.pip.aPictureInPictureState
+import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -95,9 +96,9 @@ internal fun CallScreenView(
}
CallWebView(
modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .fillMaxSize(),
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .fillMaxSize(),
url = state.urlState,
userAgent = state.userAgent,
onPermissionsRequest = { request ->
@@ -108,6 +109,8 @@ internal fun CallScreenView(
onWebViewCreate = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
+ val pipController = WebViewPipController(webView)
+ pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
}
)
when (state.urlState) {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index 3af6c7cf95..2ae53d571f 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -17,6 +17,7 @@
package io.element.android.features.call.impl.ui
import android.Manifest
+import android.app.PictureInPictureParams
import android.content.Intent
import android.content.res.Configuration
import android.media.AudioAttributes
@@ -24,19 +25,30 @@ import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
+import android.util.Rational
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat
+import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
+import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
+import io.element.android.features.call.impl.pip.PictureInPictureState
+import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
@@ -45,7 +57,10 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
import javax.inject.Inject
-class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
+class ElementCallActivity :
+ AppCompatActivity(),
+ CallScreenNavigator,
+ PipView {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@@ -86,13 +101,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
updateUiMode(resources.configuration)
}
- pictureInPicturePresenter.onCreate(this)
+ pictureInPicturePresenter.setPipView(this)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus()
setContent {
val pipState = pictureInPicturePresenter.present()
+ ListenToAndroidEvents(pipState)
ElementThemeApp(appPreferencesStore) {
val state = presenter.present()
eventSink = state.eventSink
@@ -108,21 +124,38 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
}
+ @Composable
+ private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
+ val pipEventSink by rememberUpdatedState(pipState.eventSink)
+ DisposableEffect(Unit) {
+ val onUserLeaveHintListener = Runnable {
+ pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
+ }
+ addOnUserLeaveHintListener(onUserLeaveHintListener)
+ onDispose {
+ removeOnUserLeaveHintListener(onUserLeaveHintListener)
+ }
+ }
+ DisposableEffect(Unit) {
+ val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
+ pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
+ if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ Timber.d("Exiting PiP mode: Hangup the call")
+ eventSink?.invoke(CallScreenEvents.Hangup)
+ }
+ }
+ addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
+ onDispose {
+ removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
+ }
+ }
+ }
+
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateUiMode(newConfig)
}
- override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
- super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
- pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
-
- if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
- Timber.d("Exiting PiP mode: Hangup the call")
- eventSink?.invoke(CallScreenEvents.Hangup)
- }
- }
-
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
@@ -140,16 +173,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
}
- override fun onUserLeaveHint() {
- super.onUserLeaveHint()
- pictureInPicturePresenter.onUserLeaveHint()
- }
-
override fun onDestroy() {
super.onDestroy()
releaseAudioFocus()
CallForegroundService.stop(this)
- pictureInPicturePresenter.onDestroy()
+ pictureInPicturePresenter.setPipView(null)
}
override fun finish() {
@@ -249,6 +277,33 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
}
}
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun setPipParams() {
+ setPictureInPictureParams(getPictureInPictureParams())
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun enterPipMode(): Boolean {
+ return enterPictureInPictureMode(getPictureInPictureParams())
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun getPictureInPictureParams(): PictureInPictureParams {
+ return PictureInPictureParams.Builder()
+ // Portrait for calls seems more appropriate
+ .setAspectRatio(Rational(3, 5))
+ .apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setAutoEnterEnabled(true)
+ }
+ }
+ .build()
+ }
+
+ override fun hangUp() {
+ eventSink?.invoke(CallScreenEvents.Hangup)
+ }
}
internal fun mapWebkitPermissions(permissions: Array): List {
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
similarity index 63%
rename from features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt
rename to features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
index 883a5bf97b..01e23ab727 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
@@ -14,15 +14,10 @@
* limitations under the License.
*/
-package io.element.android.features.lockscreen.impl.unlock
+package io.element.android.features.call.impl.utils
-import io.element.android.features.lockscreen.impl.unlock.signout.SignOut
-import io.element.android.tests.testutils.simulateLongTask
-
-class FakeSignOut(
- var lambda: () -> String? = { null }
-) : SignOut {
- override suspend fun invoke(): String? = simulateLongTask {
- lambda()
- }
+interface PipController {
+ suspend fun canEnterPip(): Boolean
+ fun enterPip()
+ fun exitPip()
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
new file mode 100644
index 0000000000..2a90965c8c
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.utils
+
+import android.webkit.WebView
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class WebViewPipController(
+ private val webView: WebView,
+) : PipController {
+ override suspend fun canEnterPip(): Boolean {
+ return suspendCoroutine { continuation ->
+ webView.evaluateJavascript("controls.canEnterPip()") { result ->
+ // Note if the method is not available, it will return "null"
+ continuation.resume(result == "true" || result == "null")
+ }
+ }
+ }
+
+ override fun enterPip() {
+ webView.evaluateJavascript("controls.enablePip()", null)
+ }
+
+ override fun exitPip() {
+ webView.evaluateJavascript("controls.disablePip()", null)
+ }
+}
diff --git a/features/call/impl/src/main/res/values-nl/translations.xml b/features/call/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..dd39bb9fe6
--- /dev/null
+++ b/features/call/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Actieve oproep"
+ "Tik om terug te gaan naar het gesprek"
+ "☎️ In gesprek"
+
diff --git a/features/call/impl/src/main/res/values-pl/translations.xml b/features/call/impl/src/main/res/values-pl/translations.xml
index 27133cd91b..e63fc80d14 100644
--- a/features/call/impl/src/main/res/values-pl/translations.xml
+++ b/features/call/impl/src/main/res/values-pl/translations.xml
@@ -3,4 +3,5 @@
"Połączenie w trakcie"
"Stuknij, aby wrócić do rozmowy"
"☎️ Rozmowa w toku"
+ "Przychodzące połączenie Element"
diff --git a/features/call/impl/src/main/res/values-sv/translations.xml b/features/call/impl/src/main/res/values-sv/translations.xml
index 8b76a70818..b5ee7bc2da 100644
--- a/features/call/impl/src/main/res/values-sv/translations.xml
+++ b/features/call/impl/src/main/res/values-sv/translations.xml
@@ -3,4 +3,5 @@
"Pågående samtal"
"Tryck för att återgå till samtalet"
"☎️ Samtal pågår"
+ "Inkommande Element Call"
diff --git a/features/call/impl/src/main/res/values-uz/translations.xml b/features/call/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..010695a2ef
--- /dev/null
+++ b/features/call/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Davom etayotgan qo\'ng\'iroq"
+ "Qo\'ng\'iroqqa qaytish uchun bosing"
+ "☎️ Qo‘ng‘iroq davom etmoqda"
+
diff --git a/features/call/impl/src/main/res/values-zh/translations.xml b/features/call/impl/src/main/res/values-zh/translations.xml
index 4d57257d19..8eda0df212 100644
--- a/features/call/impl/src/main/res/values-zh/translations.xml
+++ b/features/call/impl/src/main/res/values-zh/translations.xml
@@ -3,4 +3,5 @@
"通话进行中"
"点按即可返回通话"
"☎️ 通话中"
+ "Element 来电"
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
new file mode 100644
index 0000000000..086feecd39
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import io.element.android.features.call.impl.utils.PipController
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakePipController(
+ private val canEnterPipResult: () -> Boolean = { lambdaError() },
+ private val enterPipResult: () -> Unit = { lambdaError() },
+ private val exitPipResult: () -> Unit = { lambdaError() },
+) : PipController {
+ override suspend fun canEnterPip(): Boolean = canEnterPipResult()
+
+ override fun enterPip() = enterPipResult()
+
+ override fun exitPip() = exitPipResult()
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
new file mode 100644
index 0000000000..d07eb52c90
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakePipView(
+ private val setPipParamsResult: () -> Unit = { lambdaError() },
+ private val enterPipModeResult: () -> Boolean = { lambdaError() },
+ private val handUpResult: () -> Unit = { lambdaError() }
+) : PipView {
+ override fun setPipParams() = setPipParamsResult()
+ override fun enterPipMode(): Boolean = enterPipModeResult()
+ override fun hangUp() = handUpResult()
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
index 895505c278..e433a09378 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
@@ -16,23 +16,16 @@
package io.element.android.features.call.impl.pip
-import android.os.Build.VERSION_CODES
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.impl.ui.ElementCallActivity
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.Robolectric
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-@RunWith(RobolectricTestRunner::class)
class PictureInPicturePresenterTest {
@Test
- @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) {
@@ -41,68 +34,119 @@ class PictureInPicturePresenterTest {
val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse()
}
- presenter.onDestroy()
+ presenter.setPipView(null)
}
@Test
- @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
fun `when pip is supported, the state value supportPip is true`() = runTest {
- val presenter = createPictureInPicturePresenter(supportPip = true)
+ val presenter = createPictureInPicturePresenter(
+ supportPip = true,
+ pipView = FakePipView(setPipParamsResult = { }),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue()
}
- presenter.onDestroy()
}
@Test
- @Config(sdk = [VERSION_CODES.S])
fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest {
- val presenter = createPictureInPicturePresenter(supportPip = true)
+ val enterPipModeResult = lambdaRecorder { true }
+ val presenter = createPictureInPicturePresenter(
+ supportPip = true,
+ pipView = FakePipView(
+ setPipParamsResult = { },
+ enterPipModeResult = enterPipModeResult,
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
- presenter.onPictureInPictureModeChanged(true)
+ enterPipModeResult.assertions().isCalledOnce()
+ initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip
- presenter.onPictureInPictureModeChanged(false)
+ initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
}
- presenter.onDestroy()
}
@Test
- @Config(sdk = [VERSION_CODES.S])
- fun `when onUserLeaveHint is called, the state value isInPictureInPicture becomes true`() = runTest {
- val presenter = createPictureInPicturePresenter(supportPip = true)
+ fun `with webPipApi, when entering pip is supported, but web deny it, the call is finished`() = runTest {
+ val handUpResult = lambdaRecorder { }
+ val presenter = createPictureInPicturePresenter(
+ supportPip = true,
+ pipView = FakePipView(
+ setPipParamsResult = { },
+ handUpResult = handUpResult
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.isInPictureInPicture).isFalse()
- presenter.onUserLeaveHint()
- presenter.onPictureInPictureModeChanged(true)
+ initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false })))
+ initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ handUpResult.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `with webPipApi, when entering pip is supported, and web allows it, the state value isInPictureInPicture is true`() = runTest {
+ val enterPipModeResult = lambdaRecorder { true }
+ val enterPipResult = lambdaRecorder { }
+ val exitPipResult = lambdaRecorder { }
+ val presenter = createPictureInPicturePresenter(
+ supportPip = true,
+ pipView = FakePipView(
+ setPipParamsResult = { },
+ enterPipModeResult = enterPipModeResult
+ ),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(
+ PictureInPictureEvents.SetPipController(
+ FakePipController(
+ canEnterPipResult = { true },
+ enterPipResult = enterPipResult,
+ exitPipResult = exitPipResult,
+ )
+ )
+ )
+ initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ enterPipModeResult.assertions().isCalledOnce()
+ enterPipResult.assertions().isNeverCalled()
+ initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
+ enterPipResult.assertions().isCalledOnce()
+ // User stops pip
+ exitPipResult.assertions().isNeverCalled()
+ initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
+ val finalState = awaitItem()
+ assertThat(finalState.isInPictureInPicture).isFalse()
+ exitPipResult.assertions().isCalledOnce()
}
- presenter.onDestroy()
}
private fun createPictureInPicturePresenter(
supportPip: Boolean = true,
+ pipView: PipView? = FakePipView()
): PictureInPicturePresenter {
- val activity = Robolectric.buildActivity(ElementCallActivity::class.java)
return PictureInPicturePresenter(
pipSupportProvider = FakePipSupportProvider(supportPip),
).apply {
- onCreate(activity.get())
+ setPipView(pipView)
}
}
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
index 6d15e5001c..ec75b9a1fa 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
@@ -37,7 +37,7 @@ class CallScreenViewTest {
@Test
fun `clicking on back when pip is not supported hangs up`() {
val eventsRecorder = EventsRecorder()
- val pipEventsRecorder = EventsRecorder(expectEvents = false)
+ val pipEventsRecorder = EventsRecorder()
rule.setCallScreenView(
aCallScreenState(
eventSink = eventsRecorder
@@ -51,6 +51,8 @@ class CallScreenViewTest {
eventsRecorder.assertSize(2)
eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
+ pipEventsRecorder.assertSize(1)
+ pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
}
@Test
@@ -69,7 +71,9 @@ class CallScreenViewTest {
rule.pressBack()
eventsRecorder.assertSize(1)
eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
- pipEventsRecorder.assertSingle(PictureInPictureEvents.EnterPictureInPicture)
+ pipEventsRecorder.assertSize(2)
+ pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
+ pipEventsRecorder.assertTrue(1) { it == PictureInPictureEvents.EnterPictureInPicture }
}
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
index 09e6c86158..f56163b25d 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
@@ -43,11 +44,13 @@ import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilTimeout
+import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope
@@ -86,8 +89,9 @@ class CallScreenPresenterTest {
@Test
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
val sendCallNotificationIfNeededLambda = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
- val client = FakeMatrixClient().apply {
+ val client = FakeMatrixClient(syncService = syncService).apply {
givenGetRoomResult(A_ROOM_ID, fakeRoom)
}
val widgetDriver = FakeMatrixWidgetDriver()
@@ -216,7 +220,12 @@ class CallScreenPresenterTest {
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
- val matrixClient = FakeMatrixClient()
+ val syncStateFlow = MutableStateFlow(SyncState.Idle)
+ val startSyncLambda = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(syncStateFlow = syncStateFlow).apply {
+ this.startSyncLambda = startSyncLambda
+ }
+ val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
@@ -230,7 +239,7 @@ class CallScreenPresenterTest {
}.test {
consumeItemsUntilTimeout()
- assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Running)
+ assert(startSyncLambda).isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
@@ -240,7 +249,12 @@ class CallScreenPresenterTest {
fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
- val matrixClient = FakeMatrixClient()
+ val syncStateFlow = MutableStateFlow(SyncState.Running)
+ val stopSyncLambda = lambdaRecorder> { Result.success(Unit) }
+ val syncService = FakeSyncService(syncStateFlow = syncStateFlow).apply {
+ this.stopSyncLambda = stopSyncLambda
+ }
+ val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
@@ -262,7 +276,7 @@ class CallScreenPresenterTest {
job.cancelAndJoin()
- assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Terminated)
+ assert(stopSyncLambda).isCalledOnce()
}
private fun TestScope.createCallScreenPresenter(
diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..2cdb3202a5
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Nieuwe kamer"
+ "Mensen uitnodigen"
+ "Er is een fout opgetreden bij het aanmaken van de kamer"
+ "Berichten in deze kamer zijn versleuteld. Versleuteling kan achteraf niet worden uitgeschakeld."
+ "Privé kamer (alleen op uitnodiging)"
+ "Berichten zijn niet versleuteld en iedereen kan ze lezen. Je kunt versleuteling later inschakelen."
+ "Openbare kamer (iedereen)"
+ "Naam van de kamer"
+ "Creëer een kamer"
+ "Onderwerp (optioneel)"
+ "Er is een fout opgetreden bij het starten van een chat"
+
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
index eb64b46d8c..e0b19a0c49 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -2,11 +2,11 @@
"Nowy pokój"
"Zaproś znajomych"
- "Wystąpił błąd podczas tworzenia pokoju"
+ "Wystąpił błąd w trakcie tworzenia pokoju"
"Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."
"Pokój prywatny (tylko zaproszenie)"
"Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."
- "Pokój publiczny (każdy)"
+ "Pokój publiczny (wszyscy)"
"Nazwa pokoju"
"Utwórz pokój"
"Temat (opcjonalnie)"
diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..649bdf8613
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Yangi xona"
+ "Odamlarni taklif qiling"
+ "Xonani yaratishda xatolik yuz berdi"
+ "Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni o‘chirib bo‘lmaydi."
+ "Shaxsiy xona (faqat taklif)"
+ "Xabarlar shifrlanmagan va har kim ularni o\'qiy oladi. Keyinchalik shifrlashni yoqishingiz mumkin."
+ "Jamoat xonasi (har kim)"
+ "Xona nomi"
+ "Xonani yaratish"
+ "Mavzu (ixtiyoriy)"
+ "Suhbatni boshlashda xatolik yuz berdi"
+
diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml
index 4a5c2120a2..d398cfdbd0 100644
--- a/features/createroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh/translations.xml
@@ -5,7 +5,7 @@
"创建房间时出错"
"此聊天室中的消息已加密。加密无法禁用。"
"私人房间(仅限受邀者)"
- "消息未加密,任何人都可以查看。你可以稍后启用加密。"
+ "消息未加密,任何人都可以查看。可以稍后启用加密。"
"公共房间(任何人)"
"房间名称"
"创建房间"
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
index a5dc840267..3e4ed218a5 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
@@ -58,6 +58,9 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
+
+ @Parcelize
+ data object ResetIdentity : NavTarget
}
interface Callback : Plugin {
@@ -85,6 +88,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun onDone() {
plugins().forEach { it.onDone() }
}
+
+ override fun onResetKey() {
+ backstack.push(NavTarget.ResetIdentity)
+ }
})
.build()
}
@@ -94,6 +101,16 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
.callback(secureBackupEntryPointCallback)
.build()
}
+ is NavTarget.ResetIdentity -> {
+ secureBackupEntryPoint.nodeBuilder(this, buildContext)
+ .params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.ResetIdentity))
+ .callback(object : SecureBackupEntryPoint.Callback {
+ override fun onDone() {
+ plugins().forEach { it.onDone() }
+ }
+ })
+ .build()
+ }
}
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 24c9c7df82..6d22bb7b2c 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -98,7 +98,10 @@ class DefaultFtueService @Inject constructor(
} else {
getNextStep(FtueStep.AnalyticsOptIn)
}
- FtueStep.AnalyticsOptIn -> null
+ FtueStep.AnalyticsOptIn -> {
+ updateState()
+ null
+ }
}
private suspend fun isAnyStepIncomplete(): Boolean {
diff --git a/features/ftue/impl/src/main/res/values-nl/translations.xml b/features/ftue/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..1975f26467
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Je kunt je instellingen later wijzigen."
+ "Sta meldingen toe en mis nooit meer een bericht"
+ "Oproepen, peilingen, zoekopdrachten en meer zullen later dit jaar worden toegevoegd."
+ "Berichtgeschiedenis voor versleutelde kamers is nog niet beschikbaar."
+ "We horen graag van je, laat ons weten wat je ervan vindt via de instellingenpagina."
+ "Aan de slag!"
+ "Dit is wat je moet weten:"
+ "Welkom bij %1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-uz/translations.xml b/features/ftue/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..f8ede2ecf4
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Sozlamalaringizni keyinroq o\'zgartirishingiz mumkin."
+ "Bildirishnomalarga ruxsat bering va hech qachon xabarni o\'tkazib yubormang"
+ "Qo\'ng\'iroqlar, so\'ro\'vlar, qidiruv va boshqalar shu yil oxirida qo\'shiladi."
+ "Shifrlangan xonalar uchun xabarlar tarixi hali mavjud emas."
+ "Biz sizdan eshitishni istardik, sozlamalar sahifasi orqali fikringizni bildiring."
+ "Qani ketdik!"
+ "Buni bilishingiz kerak:"
+ "%1$sga Xush kelibsiz!"
+
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
index 73a82a71f5..95bebc07e6 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
@@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import kotlinx.coroutines.CoroutineScope
@@ -107,7 +108,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
) = launch {
acceptedAction.runUpdatingState {
joinRoom(
- roomId = roomId,
+ roomIdOrAlias = roomId.toRoomIdOrAlias(),
serverNames = emptyList(),
trigger = JoinedRoom.Trigger.Invite,
)
diff --git a/features/invite/impl/src/main/res/values-nl/translations.xml b/features/invite/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..25eee1572d
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?"
+ "Uitnodiging weigeren"
+ "Weet je zeker dat je deze privéchat met %1$s wilt weigeren?"
+ "Chat weigeren"
+ "Geen uitnodigingen"
+ "%1$s (%2$s) heeft je uitgenodigd"
+
diff --git a/features/invite/impl/src/main/res/values-pl/translations.xml b/features/invite/impl/src/main/res/values-pl/translations.xml
index e33b1ae106..95283e4cba 100644
--- a/features/invite/impl/src/main/res/values-pl/translations.xml
+++ b/features/invite/impl/src/main/res/values-pl/translations.xml
@@ -1,6 +1,6 @@
- "Czy na pewno chcesz odrzucić zaproszenie do dołączenia do %1$s?"
+ "Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"
"Odrzuć zaproszenie"
"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
"Odrzuć czat"
diff --git a/features/invite/impl/src/main/res/values-uz/translations.xml b/features/invite/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..78e3975700
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"
+ "Taklifni rad etish"
+ "Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"
+ "Chatni rad etish"
+ "Takliflar yo\'q"
+ "%1$s(%2$s ) sizni taklif qildi"
+
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
index 39a0321b01..239ac9c597 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
@@ -23,7 +23,9 @@ import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
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.SessionId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
@@ -178,8 +180,8 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite error flow`() = runTest {
- val joinRoomFailure = lambdaRecorder { roomId: RoomId, _: List, _: JoinedRoom.Trigger ->
- Result.failure(RuntimeException("Failed to join room $roomId"))
+ val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
+ Result.failure(RuntimeException("Failed to join room $roomIdOrAlias"))
}
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
presenter.test {
@@ -208,7 +210,7 @@ class AcceptDeclineInvitePresenterTest {
assert(joinRoomFailure)
.isCalledOnce()
.with(
- value(A_ROOM_ID),
+ value(A_ROOM_ID.toRoomIdOrAlias()),
value(emptyList()),
value(JoinedRoom.Trigger.Invite)
)
@@ -222,7 +224,7 @@ class AcceptDeclineInvitePresenterTest {
val fakeNotificationCleaner = FakeNotificationCleaner(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
- val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger ->
+ val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
val presenter = createAcceptDeclineInvitePresenter(
@@ -248,7 +250,7 @@ class AcceptDeclineInvitePresenterTest {
assert(joinRoomSuccess)
.isCalledOnce()
.with(
- value(A_ROOM_ID),
+ value(A_ROOM_ID.toRoomIdOrAlias()),
value(emptyList()),
value(JoinedRoom.Trigger.Invite)
)
@@ -271,7 +273,7 @@ class AcceptDeclineInvitePresenterTest {
private fun createAcceptDeclineInvitePresenter(
client: MatrixClient = FakeMatrixClient(),
- joinRoomLambda: (RoomId, List, JoinedRoom.Trigger) -> Result = { _, _, _ ->
+ joinRoomLambda: (RoomIdOrAlias, List, JoinedRoom.Trigger) -> Result = { _, _, _ ->
Result.success(Unit)
},
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index bba2a82a34..3fb4659c96 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -96,7 +96,7 @@ class JoinRoomPresenter @AssistedInject constructor(
}
else -> {
value = ContentState.Loading(roomIdOrAlias)
- val result = matrixClient.getRoomPreviewFromRoomId(roomId, serverNames)
+ val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames)
value = result.fold(
onSuccess = { roomPreview ->
roomPreview.toContentState()
@@ -153,7 +153,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private fun CoroutineScope.joinRoom(joinAction: MutableState>) = launch {
joinAction.runUpdatingState {
joinRoom.invoke(
- roomId = roomId,
+ roomIdOrAlias = roomIdOrAlias,
serverNames = serverNames,
trigger = trigger
)
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
index ab66d0d80c..f71ece39af 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
@@ -37,7 +37,11 @@ data class JoinRoomState(
val eventSink: (JoinRoomEvents) -> Unit
) {
val joinAuthorisationStatus = when (contentState) {
+ // Use the join authorisation status from the loaded content state
is ContentState.Loaded -> contentState.joinAuthorisationStatus
+ // Assume that if the room is unknown, the user can join it
+ is ContentState.UnknownRoom -> JoinAuthorisationStatus.CanJoin
+ // Otherwise assume that the user can't join the room
else -> JoinAuthorisationStatus.Unknown
}
}
diff --git a/features/joinroom/impl/src/main/res/values-pl/translations.xml b/features/joinroom/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..585169fa80
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Dołącz do pokoju"
+ "Zapukaj, by dołączyć"
+ "%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web."
+ "Przestrzenie nie są jeszcze obsługiwane"
+ "Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy."
+ "Musisz być członkiem tego pokoju, aby wyświetlić historię wiadomości."
+ "Chcesz dołączyć do tego pokoju?"
+ "Podgląd nie jest dostępny"
+
diff --git a/features/joinroom/impl/src/main/res/values-sv/translations.xml b/features/joinroom/impl/src/main/res/values-sv/translations.xml
new file mode 100644
index 0000000000..0181a01381
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-sv/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Gå med i rummet"
+ "Knacka för att gå med"
+ "%1$s stöder inte utrymmen än. Du kan komma åt utrymmen på webben."
+ "Utrymmen stöds inte ännu"
+ "Klicka på knappen nedan så kommer en rumsadministratör att meddelas. Du kommer att kunna gå med i konversationen när den har godkänts."
+ "Du måste vara medlem i det här rummet för att se meddelandehistoriken."
+ "Vill du gå med i det här rummet?"
+ "Förhandsgranskning är inte tillgänglig"
+
diff --git a/features/joinroom/impl/src/main/res/values-uk/translations.xml b/features/joinroom/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..ba19245e40
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Приєднатися до кімнати"
+ "Постукати, щоб приєднатися"
+ "%1$s ще не підтримує простори. Ви можете отримати доступ до них в вебверсії."
+ "Простори поки що не підтримуються"
+ "Натисніть кнопку нижче, і адміністратор кімнати отримає сповіщення. Ви зможете приєднатися до розмови після схвалення."
+ "Ви мусите бути учасником цієї кімнати, щоб переглядати історію повідомлень."
+ "Хочете приєднатися до цієї кімнати?"
+ "Попередній перегляд недоступний"
+
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index 7a6e08244c..b636e1497c 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@@ -180,7 +181,7 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is joined with success, all the parameters are provided`() = runTest {
val aTrigger = JoinedRoom.Trigger.MobilePermalink
- val joinRoomLambda = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger ->
+ val joinRoomLambda = lambdaRecorder { _: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
val presenter = createJoinRoomPresenter(
@@ -201,7 +202,7 @@ class JoinRoomPresenterTest {
}
joinRoomLambda.assertions()
.isCalledOnce()
- .with(value(A_ROOM_ID), value(A_SERVER_LIST), value(aTrigger))
+ .with(value(A_ROOM_ID.toRoomIdOrAlias()), value(A_SERVER_LIST), value(aTrigger))
}
}
@@ -366,7 +367,7 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is not known RoomPreview is loaded`() = runTest {
val client = FakeMatrixClient(
- getRoomPreviewFromRoomIdResult = { _, _ ->
+ getRoomPreviewResult = { _, _ ->
Result.success(
RoomPreview(
roomId = A_ROOM_ID,
@@ -411,7 +412,7 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is not known RoomPreview is loaded with error`() = runTest {
val client = FakeMatrixClient(
- getRoomPreviewFromRoomIdResult = { _, _ ->
+ getRoomPreviewResult = { _, _ ->
Result.failure(AN_EXCEPTION)
}
)
@@ -449,7 +450,7 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest {
val client = FakeMatrixClient(
- getRoomPreviewFromRoomIdResult = { _, _ ->
+ getRoomPreviewResult = { _, _ ->
Result.failure(Exception("403"))
}
)
@@ -474,7 +475,7 @@ class JoinRoomPresenterTest {
serverNames: List = emptyList(),
trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite,
matrixClient: MatrixClient = FakeMatrixClient(),
- joinRoomLambda: (RoomId, List, JoinedRoom.Trigger) -> Result = { _, _, _ ->
+ joinRoomLambda: (RoomIdOrAlias, List, JoinedRoom.Trigger) -> Result = { _, _, _ ->
Result.success(Unit)
},
knockRoom: KnockRoom = FakeKnockRoom(),
diff --git a/features/leaveroom/api/src/main/res/values-nl/translations.xml b/features/leaveroom/api/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..bef0aff089
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-nl/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Weet je zeker dat je dit gesprek wilt verlaten? Dit gesprek is niet openbaar en je kunt niet opnieuw deelnemen zonder een uitnodiging."
+ "Weet je zeker dat je deze kamer wilt verlaten? Je bent de enige persoon hier. Als je weggaat, kan er in de toekomst niemand meer toetreden, ook jij niet."
+ "Weet je zeker dat je deze kamer wilt verlaten? Deze kamer is niet openbaar en je kunt niet opnieuw deelnemen zonder een uitnodiging."
+ "Weet je zeker dat je de kamer wilt verlaten?"
+
diff --git a/features/leaveroom/api/src/main/res/values-uz/translations.xml b/features/leaveroom/api/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..59c111e2ac
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-uz/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Siz bu yerda yagona odamsiz. Agar siz tark etsangiz, kelajakda hech kim qo\'shila olmaydi, jumladan siz ham."
+ "Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu xona ochiq emas va siz taklifsiz qayta qo‘shila olmaysiz."
+ "Xonani tark etmoqchi ekanligingizga ishonchingiz komilmi?"
+
diff --git a/features/leaveroom/api/src/main/res/values-zh/translations.xml b/features/leaveroom/api/src/main/res/values-zh/translations.xml
index 634c1f045a..9d5f0a5425 100644
--- a/features/leaveroom/api/src/main/res/values-zh/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-zh/translations.xml
@@ -1,7 +1,7 @@
"您确定要离开此对话吗?此对话不公开,未经邀请您将无法重新加入。"
- "你确定要离开这个房间吗?这里只有你一个人,如果你走了,包括你在内的所有人都无法进入此房间。"
- "你确定要离开这个房间吗?这个房间不是公开的,如果没有邀请,你将无法重新加入。"
- "你确定要离开房间吗?"
+ "确定要离开这个房间吗?这里只有你一个人。如果你离开此房间,包括你在内的所有人都将无法进入。"
+ "确定要离开这个房间吗?此房间不公开,没有邀请你将无法重新加入。"
+ "确定要离开房间吗?"
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
index 21f3e05421..b8dc1c5035 100644
--- a/features/lockscreen/impl/build.gradle.kts
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -41,6 +41,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
+ implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api)
@@ -59,4 +60,5 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
+ testImplementation(projects.features.logout.test)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
index db56b8c17b..b563b3ac70 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
@@ -29,7 +29,7 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockMana
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
-import io.element.android.features.lockscreen.impl.unlock.signout.SignOut
+import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -41,7 +41,7 @@ import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
- private val signOut: SignOut,
+ private val logoutUseCase: LogoutUseCase,
private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
) : Presenter {
@@ -179,7 +179,7 @@ class PinUnlockPresenter @Inject constructor(
private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch {
suspend {
- signOut()
+ logoutUseCase.logout(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction)
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt
index ddd62d2fb6..0f71222ebd 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt
@@ -18,9 +18,9 @@ package io.element.android.features.lockscreen.impl.unlock.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
-import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SessionScope
-@ContributesTo(AppScope::class)
+@ContributesTo(SessionScope::class)
interface PinUnlockBindings {
fun inject(activity: PinUnlockActivity)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt
deleted file mode 100644
index 2c541911e6..0000000000
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.lockscreen.impl.unlock.signout
-
-import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.libraries.di.AppScope
-import io.element.android.libraries.matrix.api.MatrixClientProvider
-import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import javax.inject.Inject
-
-@ContributesBinding(AppScope::class)
-class DefaultSignOut @Inject constructor(
- private val authenticationService: MatrixAuthenticationService,
- private val matrixClientProvider: MatrixClientProvider,
-) : SignOut {
- override suspend fun invoke(): String? {
- val currentSession = authenticationService.getLatestSessionId()
- return if (currentSession != null) {
- matrixClientProvider.getOrRestore(currentSession)
- .getOrThrow()
- .logout(ignoreSdkError = true)
- } else {
- error("No session to sign out")
- }
- }
-}
diff --git a/features/lockscreen/impl/src/main/res/values-nl/translations.xml b/features/lockscreen/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..419d6d5518
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,37 @@
+
+
+ "biometrische authenticatie"
+ "biometrische ontgrendeling"
+ "Ontgrendelen met biometrie"
+ "Pincode vergeten?"
+ "Pincode wijzigen"
+ "Biometrische ontgrendeling toestaan"
+ "Pincode verwijderen"
+ "Weet je zeker dat je de pincode wilt verwijderen?"
+ "Pincode verwijderen?"
+ "%1$s toestaan"
+ "Ik gebruik liever een pincode"
+ "Bespaar jezelf tijd en gebruik %1$s om de app elke keer te ontgrendelen"
+ "Kies je pincode"
+ "Bevestig pincode"
+ "Vergrendel %1$s om je chats extra te beveiligen.
+
+Kies iets dat je kunt onthouden. Als je deze pincode vergeet, word je uitgelogd bij de app."
+ "Vanwege veiligheidsredenen kun je dit niet als je pincode kiezen"
+ "Kies een andere pincode"
+ "Voer dezelfde pincode twee keer in"
+ "Pincodes komen niet overeen"
+ "Je moet opnieuw inloggen en een nieuwe pincode aanmaken om verder te gaan"
+ "Je wordt uitgelogd"
+
+ - "Je hebt %1$d poging om te ontgrendelen"
+ - "Je hebt %1$d pogingen om te ontgrendelen"
+
+
+ - "Verkeerde pincode. Je hebt nog %1$d kans"
+ - "Verkeerde pincode. Je hebt nog %1$d kansen"
+
+ "Biometrie gebruiken"
+ "Pincode gebruiken"
+ "Uitloggen…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
index 1eb904b13e..7f212e6446 100644
--- a/features/lockscreen/impl/src/main/res/values-pl/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
@@ -16,11 +16,11 @@
"Potwierdź PIN"
"Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów.
-Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PINU, zostaniesz wylogowany z aplikacji."
- "Nie możesz wybrać tego PINU ze względów bezpieczeństwa"
+Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wylogowany z aplikacji."
+ "Nie możesz wybrać tego PIN\'u ze względów bezpieczeństwa"
"Wybierz inny kod PIN"
"Wprowadź ten sam kod PIN dwa razy"
- "PINY nie pasują do siebie"
+ "PIN\'y nie pasują do siebie"
"Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN"
"Trwa wylogowywanie"
diff --git a/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml
index b7af1aba25..da8b201b6d 100644
--- a/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,14 +1,27 @@
+ "autenticação por biometria"
+ "desbloqueio por biometria"
+ "Desbloquear com biometria"
"Esqueceu o PIN?"
- "Mudar código de PIN"
+ "Alterar código de PIN"
"Permitir desbloqueio biométrico"
"Remover PIN"
"Tem certeza de que quer remover o PIN?"
"Remover PIN?"
+ "Permitir %1$s"
+ "Prefiro usar o PIN"
+ "Poupe tempo e use %1$s para desbloquear o aplicativo todas as vezes"
"Escolher PIN"
"Confirmar PIN"
+ "Bloqueie o %1$s para adicionar uma segurança extra às suas conversas.
+
+Escolha algo memorável. Se você esquecer este PIN, você será desconectado do app."
+ "Você não pode escolher este PIN por razões de segurança"
+ "Escolha um PIN diferente"
+ "Por favor, insira o mesmo PIN duas vezes"
"Os PINs não correspondem"
+ "Você terá que fazer login novamente e criar um novo PIN para prosseguir"
"Você está sendo desconectado"
- "Você tem %1$d tentativa de debloqueio"
@@ -18,5 +31,7 @@
- "PIN incorreto. Você tem mais %1$d chance"
- "PIN incorreto. Você tem mais %1$d chances"
+ "Usar biometria"
+ "Usar PIN"
"Saindo…"
diff --git a/features/lockscreen/impl/src/main/res/values-uz/translations.xml b/features/lockscreen/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..b9f1c4f5cb
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Chiqish…"
+
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt
deleted file mode 100644
index 392693d9ef..0000000000
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright (c) 2024 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.lockscreen.impl.unlock
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.lockscreen.impl.unlock.signout.DefaultSignOut
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
-import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
-import io.element.android.tests.testutils.lambda.assert
-import io.element.android.tests.testutils.lambda.lambdaRecorder
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class DefaultSignOutTest {
- private val matrixClient = FakeMatrixClient()
- private val authenticationService = FakeMatrixAuthenticationService()
- private val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
- private val sut = DefaultSignOut(authenticationService, matrixClientProvider)
-
- @Test
- fun `when no active session then it throws`() = runTest {
- authenticationService.getLatestSessionIdLambda = { null }
- val result = runCatching { sut.invoke() }
- assertThat(result.isFailure).isTrue()
- }
-
- @Test
- fun `with one active session and successful logout on client`() = runTest {
- val logoutLambda = lambdaRecorder { _: Boolean -> null }
- authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId }
- matrixClient.logoutLambda = logoutLambda
- val result = runCatching { sut.invoke() }
- assertThat(result.isSuccess).isTrue()
- assert(logoutLambda).isCalledOnce()
- }
-
- @Test
- fun `with one active session and and failed logout on client`() = runTest {
- val logoutLambda = lambdaRecorder { _: Boolean -> error("Failed to logout") }
- authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId }
- matrixClient.logoutLambda = logoutLambda
- val result = runCatching { sut.invoke() }
- assertThat(result.isFailure).isTrue()
- assert(logoutLambda).isCalledOnce()
- }
-}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
index 89d0e92ee2..8299b5a1ab 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
@@ -28,7 +28,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
-import io.element.android.features.lockscreen.impl.unlock.signout.SignOut
+import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -106,9 +106,9 @@ class PinUnlockPresenterTest {
@Test
fun `present - forgot pin flow`() = runTest {
- val signOutLambda = lambdaRecorder { null }
- val signOut = FakeSignOut(signOutLambda)
- val presenter = createPinUnlockPresenter(this, signOut = signOut)
+ val signOutLambda = lambdaRecorder { "" }
+ val signOut = FakeLogoutUseCase(signOutLambda)
+ val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -135,7 +135,7 @@ class PinUnlockPresenterTest {
awaitItem().also { state ->
assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java)
}
- assert(signOutLambda).isCalledOnce().withNoParameter()
+ assert(signOutLambda).isCalledOnce()
}
}
@@ -147,7 +147,7 @@ class PinUnlockPresenterTest {
scope: CoroutineScope,
biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
- signOut: SignOut = FakeSignOut(),
+ logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
): PinUnlockPresenter {
val pinCodeManager = aPinCodeManager().apply {
addCallback(callback)
@@ -156,7 +156,7 @@ class PinUnlockPresenterTest {
return PinUnlockPresenter(
pinCodeManager = pinCodeManager,
biometricUnlockManager = biometricUnlockManager,
- signOut = signOut,
+ logoutUseCase = logoutUseCase,
coroutineScope = scope,
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
)
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
index 6fcdc2c1de..3fbe55f1f9 100644
--- a/features/login/impl/build.gradle.kts
+++ b/features/login/impl/build.gradle.kts
@@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.qrcode)
+ implementation(projects.libraries.oidc.api)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
@@ -65,6 +66,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
index 0af102a403..13aac03de4 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -36,12 +36,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.api.LoginFlowType
-import io.element.android.features.login.api.oidc.OidcAction
-import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
-import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
-import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
-import io.element.android.features.login.impl.oidc.webview.OidcNode
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
@@ -56,6 +51,9 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.api.OidcActionFlow
+import io.element.android.libraries.oidc.api.OidcEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -64,11 +62,10 @@ import kotlinx.parcelize.Parcelize
class LoginFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
- private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val oidcActionFlow: OidcActionFlow,
+ private val oidcEntryPoint: OidcEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -146,11 +143,11 @@ class LoginFlowNode @AssistedInject constructor(
)
val callback = object : ConfirmAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) {
- if (customTabAvailabilityChecker.supportCustomTab()) {
+ if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
- customTabHandler.open(it, darkTheme, oidcDetails.url)
+ oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
@@ -201,8 +198,7 @@ class LoginFlowNode @AssistedInject constructor(
createNode(buildContext, plugins = listOf(callback))
}
is NavTarget.OidcView -> {
- val input = OidcNode.Inputs(navTarget.oidcDetails)
- createNode(buildContext, plugins = listOf(input))
+ oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.WaitList -> {
val inputs = WaitListNode.Inputs(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
index 27aec75739..fdf7994d7e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -27,15 +27,15 @@ import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
-import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
-import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -43,7 +43,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService,
- private val defaultOidcActionFlow: DefaultOidcActionFlow,
+ private val oidcActionFlow: OidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter {
data class Params(
@@ -65,7 +65,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
}
LaunchedEffect(Unit) {
- defaultOidcActionFlow.collect { oidcAction ->
+ oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction, loginFlowAction)
}
@@ -133,6 +133,6 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
}
}
}
- defaultOidcActionFlow.reset()
+ oidcActionFlow.reset()
}
}
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index 398bacb306..2399ec86ba 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -39,6 +39,20 @@
"Die Verbindung ist nicht sicher"
"Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."
"Trage die unten angezeigte Zahl auf einem anderen Device ein"
+ "Melde dich auf deinem anderen Gerät an und versuche es dann noch einmal oder verwende ein anderes Gerät, das bereits angemeldet ist."
+ "Anderes Gerät ist nicht angemeldet"
+ "Die Anmeldung wurde auf dem anderen Gerät abgebrochen."
+ "Anmeldeanfrage abgebrochen"
+ "Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
+ "Anmelden abgelehnt"
+ "Die Anmeldung ist abgelaufen. Bitte versuchen Sie es erneut."
+ "Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
+ "Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht.
+
+Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Gerät."
+ "QR-Code wird nicht unterstützt"
+ "Ihr Kontoanbieter unterstützt %1$s nicht."
+ "%1$swird nicht unterstützt"
"Bereit zum Scannen"
"%1$s auf einem Desktop-Gerät öffnen"
"Klick auf deinen Avatar"
diff --git a/features/login/impl/src/main/res/values-nl/translations.xml b/features/login/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..80fbe57afb
--- /dev/null
+++ b/features/login/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,44 @@
+
+
+ "Wijzig accountprovider"
+ "Homeserver-adres"
+ "Voer een zoekterm of een domeinnaam in."
+ "Zoek naar een bedrijf, community of privéserver."
+ "Vind een accountprovider"
+ "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren."
+ "Je staat op het punt om je aan te melden bij %s"
+ "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren."
+ "Je staat op het punt een account aan te maken op %s"
+ "Matrix.org is een grote, gratis server op het openbare Matrix-netwerk voor veilige, gedecentraliseerde communicatie, beheerd door de Matrix.org Foundation."
+ "Anders"
+ "Gebruik een andere accountprovider, zoals je eigen privéserver of een zakelijke account."
+ "Wijzig accountprovider"
+ "We konden deze homeserver niet bereiken. Controleer of je de homeserver-URL juist hebt ingevoerd. Als de URL juist is, neem dan contact op met de beheerder van je homeserver voor verdere hulp."
+ "Deze server ondersteunt op dit moment geen sliding sync."
+ "Homeserver-URL"
+ "Je kunt alleen verbinding maken met een bestaande server die sliding sync ondersteunt. De beheerder van de homeserver moet dit configureren. %1$s"
+ "Wat is het adres van je server?"
+ "Selecteer je server"
+ "Dit account is gedeactiveerd."
+ "Onjuiste gebruikersnaam en/of wachtwoord"
+ "Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'"
+ "Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord."
+ "De geselecteerde homeserver ondersteunt geen wachtwoord of OIDC aanmelding. Neem contact op met je beheerder of kies een andere homeserver."
+ "Vul je gegevens in"
+ "Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie."
+ "Welkom terug!"
+ "Inloggen bij %1$s"
+ "Probeer het opnieuw"
+ "Accountprovider wijzigen"
+ "Een privéserver voor medewerkers van Element."
+ "Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie."
+ "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren."
+ "Je staat op het punt je aan te melden bij %1$s"
+ "Je staat op het punt een account aan te maken op %1$s"
+ "Er is momenteel veel vraag naar %1$s op %2$s. Kom over een paar dagen terug naar de app en probeer het opnieuw.
+
+Bedankt voor je geduld!"
+ "Welkom bij %1$s!"
+ "Je bent er bijna."
+ "Je bent binnen."
+
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
index 8adfbcb8a8..4d23e15fe8 100644
--- a/features/login/impl/src/main/res/values-pl/translations.xml
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -6,7 +6,7 @@
"Szukaj serwera firmowego, społeczności lub prywatnego."
"Znajdź dostawcę konta"
"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."
- "Zamierzasz się zalogować %s"
+ "Zamierzasz zalogować się do %s"
"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."
"Zamierzasz założyć konto na %s"
"Matrix.org jest ogromnym i darmowym serwerem na publicznej sieci Matrix zapewniający bezpieczną i zdecentralizowaną komunikację zarządzaną przez Fundację Matrix.org."
@@ -14,20 +14,64 @@
"Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."
"Zmień dostawcę konta"
"Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."
+ "Sliding sync nie jest dostępny z powodu problemu w znanym pliku:
+%1$s"
"Ten serwer obecnie nie obsługuje technologii Sliding Sync."
- "Adres URL serwera domowego"
+ "URL serwera domowego"
"Możesz połączyć się tylko z serwerem, który obsługuje technologię Sliding Sync. Administrator serwera domowego będzie musiał ją skonfigurować. %1$s"
"Jaki jest adres Twojego serwera?"
"Wybierz swój serwer"
"To konto zostało dezaktywowane."
"Nieprawidłowa nazwa użytkownika i/lub hasło"
"To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'"
+ "Ten serwer został skonfigurowany do korzystania z tokenów odświeżania. Nie są one obsługiwane, gdy korzystasz z hasła."
"Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy."
"Wprowadź swoje dane"
"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."
"Witaj ponownie!"
"Zaloguj się do %1$s"
+ "Nawiązanie bezpiecznego połączenia"
+ "Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić."
+ "Co teraz?"
+ "Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią"
+ "Jeśli napotkasz ten sam problem, użyj innej sieci Wi-FI lub danych mobilnych"
+ "Jeśli to nie zadziała, zaloguj się ręcznie"
+ "Połączenie nie jest bezpieczne"
+ "Zostaniesz poproszony o wprowadzenie dwóch cyfr widocznych na tym urządzeniu."
+ "Wprowadź numer poniżej na innym urządzeniu"
+ "Zaloguj się na drugie urządzenie lub użyj tego, które jest już zalogowane, a następnie spróbuj ponownie."
+ "Drugie urządzenie nie jest zalogowane"
+ "Logowanie zostało anulowane na drugim urządzeniu."
+ "Prośba o logowanie została anulowana"
+ "Logowanie zostało odrzucone na drugim urządzeniu."
+ "Logowanie odrzucone"
+ "Logowanie wygasło. Spróbuj ponownie."
+ "Logowanie nie zostało ukończone na czas"
+ "Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR.
+
+Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."
+ "Kod QR nie jest wspierany"
+ "Twój dostawca konta nie obsługuje %1$s."
+ "%1$s nie jest wspierany"
+ "Gotowy do skanowania"
+ "Otwórz %1$s na urządzeniu stacjonarnym"
+ "Kliknij na swój awatar"
+ "Wybierz %1$s"
+ "“Powiąż nowe urządzenie”"
+ "Zeskanuj kod QR za pomocą tego urządzenia"
+ "Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR"
+ "Użyj kodu QR widocznego na drugim urządzeniu."
"Spróbuj ponownie"
+ "Błędny kod QR"
+ "Przejdź do ustawień aparatu"
+ "Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować."
+ "Zezwól na dostęp do kamery, aby zeskanować kod QR"
+ "Skanuj kod QR"
+ "Zacznij od nowa"
+ "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
+ "Oczekiwanie na drugie urządzenie"
+ "Twój dostawca konta może poprosić o podany kod, aby zweryfikować logowanie."
+ "Twój kod weryfikacyjny"
"Zmień dostawcę konta"
"Serwer prywatny dla pracowników Element."
"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."
diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml
index 0362c6c0d0..d50610adcc 100644
--- a/features/login/impl/src/main/res/values-sv/translations.xml
+++ b/features/login/impl/src/main/res/values-sv/translations.xml
@@ -30,7 +30,48 @@
"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."
"Välkommen tillbaka!"
"Logga in på %1$s"
+ "Upprättar en säker anslutning"
+ "En säker anslutning kunde inte göras till den nya enheten. Dina befintliga enheter är fortfarande säkra och du behöver inte oroa dig för dem."
+ "Nu då?"
+ "Pröva att logga in igen med en QR-kod ifall detta skulle vara ett nätverksproblem"
+ "Om du stöter på samma problem, prova ett annat wifi-nätverk eller använd din mobildata istället för wifi"
+ "Om det inte fungerar, logga in manuellt"
+ "Anslutningen är inte säker"
+ "Du kommer att bli ombedd att ange de två siffrorna som visas på den här enheten."
+ "Ange numret nedan på din andra enhet"
+ "Logga in på din andra enhet och försök sedan igen, eller använd en annan enhet som redan är inloggad."
+ "Den andra enheten är inte inloggad"
+ "Inloggningen avbröts på den andra enheten."
+ "Inloggningsförfrågan avbröts"
+ "Inloggningen avvisades på den andra enheten."
+ "Inloggning avvisad"
+ "Inloggningen har löpt ut. Vänligen försök igen."
+ "Inloggningen slutfördes inte i tid"
+ "Din andra enhet stöder inte inloggning i %s med en QR-kod.
+
+Prova att logga in manuellt eller skanna QR-koden med en annan enhet."
+ "QR-kod stöds inte"
+ "Din kontoleverantör stöder inte %1$s."
+ "%1$s stöds inte"
+ "Redo att skanna"
+ "Öppna %1$s på en skrivbordsenhet"
+ "Klicka på din avatar"
+ "Välj %1$s"
+ "”Länka ny enhet”"
+ "Skanna QR-koden med den här enheten"
+ "Öppna %1$s på en annan enhet för att få QR-koden"
+ "Använd QR-koden som visas på den andra enheten."
"Försök igen"
+ "Fel QR-kod"
+ "Gå till kamerainställningar"
+ "Du måste ge tillstånd för %1$s att använda enhetens kamera för att kunna fortsätta."
+ "Tillåt kameraåtkomst för att skanna QR-koden"
+ "Skanna QR-koden"
+ "Börja om"
+ "Ett oväntat fel inträffade. Vänligen försök igen."
+ "Väntar på din andra enhet"
+ "Din kontoleverantör kan be om följande kod för att verifiera inloggningen."
+ "Din verifieringskod"
"Byt kontoleverantör"
"En privat server för Element-anställda."
"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."
diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml
index 1e01a8eba0..9cb6eb1f5f 100644
--- a/features/login/impl/src/main/res/values-uk/translations.xml
+++ b/features/login/impl/src/main/res/values-uk/translations.xml
@@ -30,7 +30,48 @@
"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."
"З поверненням!"
"Увійти в %1$s"
+ "Встановлення безпечного з\'єднання"
+ "Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші існуючі пристрої все ще в безпеці, і вам не потрібно про них турбуватися."
+ "Що тепер?"
+ "Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею"
+ "Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi"
+ "Якщо це не спрацює, увійдіть вручну"
+ "З\'єднання не є безпечним"
+ "Вас попросять ввести дві цифри, показані на цьому пристрої."
+ "Введіть номер нижче на іншому пристрої"
+ "Увійдіть на іншому пристрої та спробуйте ще раз або скористайтеся іншим пристроєм, що вже в обліковому записі."
+ "Інший пристрій не ввійшов"
+ "Вхід було скасовано на іншому пристрої."
+ "Запит на вхід скасовано"
+ "Вхід був відхилений на іншому пристрої."
+ "Вхід відхилено"
+ "Термін входу сплив. Будь ласка, спробуйте ще раз."
+ "Вхід не було завершено вчасно"
+ "Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду.
+
+Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою."
+ "QR-код не підтримується"
+ "Постачальник вашого облікового запису не підтримує %1$s."
+ "%1$s не підтримується"
+ "Готовий до сканування"
+ "Відкрийте %1$s на комп\'ютері"
+ "Натисніть на свою аватарку"
+ "Оберіть %1$s"
+ "“Підключити новий пристрій”"
+ "Відскануйте QR-код цим пристроєм"
+ "Відкрийте %1$s на іншому пристрої, щоб отримати QR-код"
+ "Використовуйте QR-код, показаний на іншому пристрої."
"Спробуйте ще раз"
+ "Неправильний QR-код"
+ "Перейти до налаштувань камери"
+ "Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити."
+ "Надайте доступ до камери, щоб сканувати QR-код"
+ "Відскануйте QR-код"
+ "Почати спочатку"
+ "Сталася несподівана помилка. Будь ласка, спробуйте ще раз."
+ "Чекаємо на ваш інший пристрій"
+ "Постачальник облікового запису може попросити вас ввести код нижче для підтвердження входу."
+ "Ваш код підтвердження"
"Змінити провайдера облікового запису"
"Приватний сервер для співробітників Element."
"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."
diff --git a/features/login/impl/src/main/res/values-uz/translations.xml b/features/login/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..67dc5129d7
--- /dev/null
+++ b/features/login/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,42 @@
+
+
+ "Hisob provayderini o\'zgartiring"
+ "Uy server manzili"
+ "Qidiruv so\'zini yoki domen manzilini kiriting."
+ "Kompaniya, jamoa yoki shaxsiy serverni qidiring."
+ "Hisob provayderini toping"
+ "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."
+ "Siz %sga kirmoqchisiz"
+ "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."
+ "Siz %sda hisob yaratmoqchisiz"
+ "Matrix.org - bu Matrix.org Jamg\'armasi tomonidan boshqariladigan xavfsiz, markazlashtirilmagan aloqa uchun ommaviy Matrix tarmog\'idagi katta, bepul server."
+ "Boshqa"
+ "Shaxsiy serveringiz yoki ishchi hisob qaydnomangiz kabi boshqa hisob provayderidan foydalaning."
+ "Hisob provayderini o\'zgartiring"
+ "Bu uy serveriga kira olmadik. Iltimos, uy serverining URL manzilini to\'ri kiritganingizni tekshiring. Agar URL toʻgʻri boʻlsa, qoʻshimcha yordam olish uchun uy serveri administratoriga murojaat qiling."
+ "Hozirda bu server siljish sinxronlashni qo‘llab-quvvatlamaydi."
+ "Uy serverining URL manzili"
+ "Siz faqat siljish sinxronlashni qo\'llab-quvvatlaydigan mavjud serverga ulanishingiz mumkin. Uy serveringiz administratori uni sozlashi kerak.%1$s"
+ "Serveringizning manzili nima?"
+ "Serveringizni tanlang"
+ "Bu hisob o‘chirilgan."
+ "Notog\'ri foydalanuvchi nomi va/yoki parol"
+ "Bu haqiqiy foydalanuvchi identifikatori emas. Kutilayotgan format: \'@user:homeserver.org\'"
+ "Tanlangan uy serveri parol yoki OIDC loginni qo\'lab-quvvatlamaydi. Iltimos, administratoringizga murojaat qiling yoki boshqa uy serverini tanlang."
+ "Tafsilotlaringizni kiriting"
+ "Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."
+ "Qaytib kelganingizdan xursandmiz!"
+ "Kirish%1$s"
+ "Hisob provayderini o\'zgartiring"
+ "Element xodimlari uchun shaxsiy server."
+ "Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."
+ "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."
+ "Siz tizimga kirmoqchisiz%1$s"
+ "Hisob yaratmoqchisiz%1$s"
+ "Hozirgi paytda %2$sga %1$sda talab yuqori. Bir necha kundan keyin ilovaga qayting va qaytadan urining.
+
+Sabr-toqatingiz uchun rahmat!"
+ "%1$sga Xush kelibsiz!"
+ "Siz deyarli keldingiz."
+ "Siz kirdingiz."
+
diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml
index bc5421e42e..0afd6dad93 100644
--- a/features/login/impl/src/main/res/values-zh/translations.xml
+++ b/features/login/impl/src/main/res/values-zh/translations.xml
@@ -13,12 +13,12 @@
"其他"
"使用其他帐户提供者,例如您自己的私人服务器或工作帐户。"
"更改账户提供者"
- "我们无法访问此主服务器。请检查您输入的主服务器网址是否正确。如果 URL 正确,请联系您的主服务器管理员寻求进一步帮助。"
+ "我们无法访问此服务器。请检查您输入的服务器网址是否正确。如果 URL 正确,请联系您的服务器管理员寻求进一步帮助。"
"由于 Well Known 文件中的问题,Sliding Sync 不可用:
%1$s"
- "该服务器目前不支持sliding sync。"
- "主服务器网址"
- "您只能连接到支持sliding sync的现有服务器。您的主服务器管理员需要对其进行配置。%1$s"
+ "该服务器目前不支持 Sliding Sync。"
+ "服务器网址"
+ "您只能连接到支持 Sliding Sync 的现有服务器。您的服务器管理员需要对其进行配置。%1$s"
"您的服务器地址是什么?"
"选择服务器"
"该账户已被停用。"
@@ -34,11 +34,13 @@
"无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。"
"现在怎么办?"
"如果这是网络问题,请尝试使用二维码再次登录"
- "如果你遇到同样的问题,请尝试使用不同的 WiFi 网络或使用你的移动数据代替 WiFi"
+ "如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi"
"如果不起作用,请手动登录"
"连接不安全"
"您会被要求输入此设备上显示的两位数。"
"在您的其他设备上输入下面的数字"
+ "在其他设备登录后重试,或使用另一个已登录的设备。"
+ "其他设备未登录"
"登录被另一台设备取消"
"登录请求已取消"
"其它设备未接受请求"
@@ -74,8 +76,8 @@
"专为 Element 员工提供的私人服务器。"
"Matrix 是一个用于安全、去中心化通信的开放网络。"
"这是您的对话将进行的地方,就像您使用电子邮件提供商来保存电子邮件一样。"
- "你即将登录 %1$s"
- "你即将在 %1$s 上创建一个账户"
+ "即将登录 %1$s"
+ "即将在 %1$s 上创建一个账户"
"目前 %1$s 上 %2$s 的负载很大。过几天再回来试试吧。
感谢您的耐心!"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
index 5a02797bdb..3875a4b245 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -20,10 +20,8 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
-import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -31,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
+import io.element.android.libraries.oidc.api.OidcAction
+import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest
@@ -274,7 +274,7 @@ class ConfirmAccountProviderPresenterTest {
params = params,
accountProviderDataSource = accountProviderDataSource,
authenticationService = matrixAuthenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ oidcActionFlow = defaultOidcActionFlow,
defaultLoginUserStory = defaultLoginUserStory,
)
}
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt
new file mode 100644
index 0000000000..a06b7117b1
--- /dev/null
+++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.logout.api
+
+/**
+ * Used to trigger a log out of the current user from any part of the app.
+ */
+interface LogoutUseCase {
+ /**
+ * Log out the current user and then perform any needed cleanup tasks.
+ * @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway.
+ * @return the session id of the logged out user.
+ */
+ suspend fun logout(ignoreSdkError: Boolean): String
+
+ interface Factory {
+ fun create(sessionId: String): LogoutUseCase
+ }
+}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt
new file mode 100644
index 0000000000..8e5f08a87a
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.logout.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.logout.api.LogoutUseCase
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.SessionId
+
+class DefaultLogoutUseCase @AssistedInject constructor(
+ @Assisted private val sessionId: String,
+ private val matrixClientProvider: MatrixClientProvider,
+) : LogoutUseCase {
+ @ContributesBinding(AppScope::class)
+ @AssistedFactory
+ interface Factory : LogoutUseCase.Factory {
+ override fun create(sessionId: String): DefaultLogoutUseCase
+ }
+
+ override suspend fun logout(ignoreSdkError: Boolean): String {
+ val matrixClient = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrThrow()
+ matrixClient.logout(ignoreSdkError = ignoreSdkError)
+ return sessionId
+ }
+}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/SessionLogoutModule.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/SessionLogoutModule.kt
new file mode 100644
index 0000000000..ec725a34a6
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/SessionLogoutModule.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.logout.impl
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.features.logout.api.LogoutUseCase
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
+
+@Module
+@ContributesTo(SessionScope::class)
+object SessionLogoutModule {
+ @Provides
+ fun provideLogoutUseCase(
+ currentSessionIdHolder: CurrentSessionIdHolder,
+ factory: DefaultLogoutUseCase.Factory,
+ ): LogoutUseCase {
+ return factory.create(currentSessionIdHolder.current.value)
+ }
+}
diff --git a/features/logout/impl/src/main/res/values-nl/translations.xml b/features/logout/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..174102a0a2
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Weet je zeker dat je je wilt uitloggen?"
+ "Uitloggen"
+ "Uitloggen"
+ "Uitloggen…"
+ "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, verlies je de toegang tot je versleutelde berichten."
+ "Je hebt de back-up uitgeschakeld"
+ "De backup van je sleutels was nog bezig toen je offline ging. Maak opnieuw verbinding zodat er een back-up van je sleutels kan worden gemaakt voordat je uitlogt."
+ "De backup van je sleutels is nog bezig"
+ "Wacht tot dit voltooid is voordat je uitlogt."
+ "De backup van je sleutels is nog bezig"
+ "Uitloggen"
+ "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, verlies je de toegang tot je versleutelde berichten."
+ "Herstelmogelijkheid niet ingesteld"
+ "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, kan het dat je de toegang tot je versleutelde berichten verliest."
+ "Heb je je herstelsleutel opgeslagen?"
+
diff --git a/features/logout/impl/src/main/res/values-pl/translations.xml b/features/logout/impl/src/main/res/values-pl/translations.xml
index ca0d9dc4af..46a5c2d6bd 100644
--- a/features/logout/impl/src/main/res/values-pl/translations.xml
+++ b/features/logout/impl/src/main/res/values-pl/translations.xml
@@ -1,8 +1,8 @@
"Czy na pewno chcesz się wylogować?"
- "Wyloguj się"
- "Wyloguj się"
+ "Wyloguj"
+ "Wyloguj"
"Wylogowywanie…"
"Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
"Wyłączyłeś backup"
@@ -10,7 +10,7 @@
"Twoje klucze są nadal archiwizowane"
"Zanim się wylogujesz, poczekaj na zakończenie operacji."
"Twoje klucze są nadal archiwizowane"
- "Wyloguj się"
+ "Wyloguj"
"Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
"Nie ustawiono przywracania"
"Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
diff --git a/features/logout/impl/src/main/res/values-pt-rBR/translations.xml b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml
index 65ccf4ca6f..7d77b477ad 100644
--- a/features/logout/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml
@@ -4,5 +4,9 @@
"Sair"
"Sair"
"Saindo…"
+ "Você desativou o backup"
+ "O backup das suas chaves ainda está em andamento"
"Sair"
+ "A recuperação não está configurada"
+ "Você salvou sua chave de recuperação?"
diff --git a/features/logout/impl/src/main/res/values-uz/translations.xml b/features/logout/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..24da45d63b
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Haqiqatan ham tizimdan chiqmoqchimisiz?"
+ "Tizimdan chiqish"
+ "Tizimdan chiqish"
+ "Chiqish…"
+ "Tizimdan chiqish"
+
diff --git a/features/logout/impl/src/main/res/values-zh/translations.xml b/features/logout/impl/src/main/res/values-zh/translations.xml
index be1d740130..0a8ec07e87 100644
--- a/features/logout/impl/src/main/res/values-zh/translations.xml
+++ b/features/logout/impl/src/main/res/values-zh/translations.xml
@@ -4,15 +4,15 @@
"登出"
"登出"
"正在登出…"
- "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。"
+ "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
"您已关闭备份"
- "当你离线时,你的密钥仍在备份中。重新连接,以便在登出之前备份密钥。"
+ "当你离线时,密钥仍在备份中。重新连接以便在登出之前备份密钥。"
"您的密钥仍在备份中"
"请等待此操作完成后再登出。"
"您的密钥仍在备份中"
"登出"
- "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。"
+ "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
"未设置恢复"
- "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。"
+ "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
"您保存了恢复密钥吗?"
diff --git a/features/logout/test/build.gradle.kts b/features/logout/test/build.gradle.kts
new file mode 100644
index 0000000000..7be20ef279
--- /dev/null
+++ b/features/logout/test/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.logout.test"
+}
+
+dependencies {
+ implementation(libs.coroutines.core)
+ implementation(projects.tests.testutils)
+ api(projects.features.logout.api)
+}
diff --git a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt
new file mode 100644
index 0000000000..bbc67765d1
--- /dev/null
+++ b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.logout.test
+
+import io.element.android.features.logout.api.LogoutUseCase
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeLogoutUseCase(
+ var logoutLambda: (Boolean) -> String = lambdaError()
+) : LogoutUseCase {
+ override suspend fun logout(ignoreSdkError: Boolean): String {
+ return logoutLambda(ignoreSdkError)
+ }
+}
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index 781a42b2fb..c1d3067510 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -102,5 +102,6 @@ dependencies {
testImplementation(projects.features.poll.test)
testImplementation(projects.features.poll.impl)
testImplementation(libs.androidx.compose.ui.test.junit)
+ testImplementation(projects.libraries.eventformatter.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
index 3d61d43063..29bea5ca9e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
@@ -58,7 +58,11 @@ import kotlin.math.roundToInt
* @param modifier The modifier for the layout.
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
*/
-@Suppress("ContentTrailingLambda")
+@Suppress(
+ "ContentTrailingLambda",
+ // False positive
+ "MultipleEmitters",
+)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandableBottomSheetScaffold(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 4fd59be6cb..f022fd0caf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -81,6 +81,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
+import timber.log.Timber
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@@ -217,6 +218,10 @@ class MessagesFlowNode @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
}
+
+ override fun onViewAllPinnedEvents() {
+ Timber.d("On View All Pinned Events not implemented yet.")
+ }
}
val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index b2ee1053a6..d722a5b7a0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -97,6 +97,7 @@ class MessagesNode @AssistedInject constructor(
fun onCreatePollClick()
fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId)
+ fun onViewAllPinnedEvents()
}
override fun onBuilt() {
@@ -185,6 +186,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onEditPollClick(eventId) }
}
+ private fun onViewAllPinnedMessagesClick() {
+ callbacks.forEach { it.onViewAllPinnedEvents() }
+ }
+
private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() }
}
@@ -221,6 +226,7 @@ class MessagesNode @AssistedInject constructor(
onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
+ onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
modifier = modifier,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index b13a95d4f2..c17ac1035b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -20,10 +20,12 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -39,6 +41,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
@@ -73,12 +76,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
+import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
+import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
+import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall
-import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
-import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
-import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
@@ -98,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
+ private val pinnedMessagesBannerPresenter: Presenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@@ -129,12 +134,12 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
+ val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
- val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
- val userHasPermissionToRedactOwn by room.canRedactOwnAsState(updateKey = syncUpdateFlow.value)
- val userHasPermissionToRedactOther by room.canRedactOtherAsState(updateKey = syncUpdateFlow.value)
- val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
+
+ val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
+
val roomName: AsyncData by remember {
derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
}
@@ -211,11 +216,8 @@ class MessagesPresenter @AssistedInject constructor(
roomName = roomName,
roomAvatar = roomAvatar,
heroes = heroes,
- userHasPermissionToSendMessage = userHasPermissionToSendMessage,
- userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
- userHasPermissionToRedactOther = userHasPermissionToRedactOther,
- userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState,
+ userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
typingNotificationState = typingNotificationState,
@@ -231,10 +233,24 @@ class MessagesPresenter @AssistedInject constructor(
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
callState = callState,
+ pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) }
)
}
+ @Composable
+ private fun userEventPermissions(updateKey: Long): State {
+ return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
+ value = UserEventPermissions(
+ canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true },
+ canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true },
+ canRedactOwn = room.canRedactOwn().getOrElse { false },
+ canRedactOther = room.canRedactOther().getOrElse { false },
+ canPinUnpin = room.canPinUnpin().getOrElse { false },
+ )
+ }
+ }
+
private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,
@@ -268,6 +284,30 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
+ TimelineItemAction.Pin -> handlePinAction(targetEvent)
+ TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
+ }
+ }
+
+ private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
+ if (targetEvent.eventId == null) return
+ timelineController.invokeOnCurrentTimeline {
+ pinEvent(targetEvent.eventId)
+ .onFailure {
+ Timber.e(it, "Failed to pin event ${targetEvent.eventId}")
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
+ }
+ }
+ }
+
+ private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
+ if (targetEvent.eventId == null) return
+ timelineController.invokeOnCurrentTimeline {
+ unpinEvent(targetEvent.eventId)
+ .onFailure {
+ Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index e8657d70bd..172111e862 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@@ -37,10 +38,7 @@ data class MessagesState(
val roomName: AsyncData,
val roomAvatar: AsyncData,
val heroes: ImmutableList,
- val userHasPermissionToSendMessage: Boolean,
- val userHasPermissionToRedactOwn: Boolean,
- val userHasPermissionToRedactOther: Boolean,
- val userHasPermissionToSendReaction: Boolean,
+ val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
@@ -57,6 +55,7 @@ data class MessagesState(
val enableVoiceMessages: Boolean,
val callState: RoomCallState,
val appName: String,
+ val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 1396d3e17c..97436c2bc0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
+import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
@@ -53,7 +55,7 @@ open class MessagesStateProvider : PreviewParameterProvider {
aMessagesState(),
aMessagesState(hasNetworkConnection = false),
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
- aMessagesState(userHasPermissionToSendMessage = false),
+ aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true),
aMessagesState(
roomName = AsyncData.Uninitialized,
@@ -87,16 +89,19 @@ open class MessagesStateProvider : PreviewParameterProvider {
aMessagesState(
callState = RoomCallState.DISABLED,
),
+ aMessagesState(
+ pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
+ knownPinnedMessagesCount = 4,
+ currentPinnedMessageIndex = 0,
+ ),
+ ),
)
}
fun aMessagesState(
roomName: AsyncData = AsyncData.Success("Room name"),
roomAvatar: AsyncData = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
- userHasPermissionToSendMessage: Boolean = true,
- userHasPermissionToRedactOwn: Boolean = false,
- userHasPermissionToRedactOther: Boolean = false,
- userHasPermissionToSendReaction: Boolean = true,
+ userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
isFullScreen = false,
@@ -116,16 +121,14 @@ fun aMessagesState(
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
+ pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
roomAvatar = roomAvatar,
heroes = persistentListOf(),
- userHasPermissionToSendMessage = userHasPermissionToSendMessage,
- userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
- userHasPermissionToRedactOther = userHasPermissionToRedactOther,
- userHasPermissionToSendReaction = userHasPermissionToSendReaction,
+ userEventPermissions = userEventPermissions,
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(),
@@ -142,9 +145,24 @@ fun aMessagesState(
enableVoiceMessages = enableVoiceMessages,
callState = callState,
appName = "Element",
+ pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink,
)
+fun aUserEventPermissions(
+ canRedactOwn: Boolean = false,
+ canRedactOther: Boolean = false,
+ canSendMessage: Boolean = true,
+ canSendReaction: Boolean = true,
+ canPinUnpin: Boolean = false,
+) = UserEventPermissions(
+ canRedactOwn = canRedactOwn,
+ canRedactOther = canRedactOther,
+ canSendMessage = canSendMessage,
+ canSendReaction = canSendReaction,
+ canPinUnpin = canPinUnpin,
+)
+
fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 24439c0c75..f1f8b844ef 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -16,6 +16,9 @@
package io.element.android.features.messages.impl
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -63,11 +66,16 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
-import io.element.android.features.messages.impl.mentions.MentionSuggestionsPickerView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
+import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
+import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
+import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@@ -102,11 +110,13 @@ import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
import kotlin.random.Random
+import kotlin.time.Duration.Companion.milliseconds
@Composable
fun MessagesView(
@@ -120,8 +130,9 @@ fun MessagesView(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
+ onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
- forceJumpToBottomVisibility: Boolean = false
+ forceJumpToBottomVisibility: Boolean = false,
) {
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
@@ -154,10 +165,7 @@ fun MessagesView(
state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
event = event,
- canRedactOwn = state.userHasPermissionToRedactOwn,
- canRedactOther = state.userHasPermissionToRedactOther,
- canSendMessage = state.userHasPermissionToSendMessage,
- canSendReaction = state.userHasPermissionToSendReaction,
+ userEventPermissions = state.userEventPermissions,
)
)
}
@@ -225,6 +233,7 @@ fun MessagesView(
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
+ onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
)
},
snackbarHost = {
@@ -316,6 +325,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
+ onViewAllPinnedMessagesClick: () -> Unit,
forceJumpToBottomVisibility: Boolean,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
@@ -367,28 +377,47 @@ private fun MessagesViewContent(
@Composable {}
},
sheetSwipeEnabled = state.composerState.showTextFormatting,
- sheetShape = if (state.composerState.showTextFormatting || state.composerState.memberSuggestions.isNotEmpty()) {
+ sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) {
MaterialTheme.shapes.large
} else {
RectangleShape
},
content = { paddingValues ->
- TimelineView(
- state = state.timelineState,
- typingNotificationState = state.typingNotificationState,
- onUserDataClick = onUserDataClick,
- onLinkClick = onLinkClick,
- onMessageClick = onMessageClick,
- onMessageLongClick = onMessageLongClick,
- onSwipeToReply = onSwipeToReply,
- onReactionClick = onReactionClick,
- onReactionLongClick = onReactionLongClick,
- onMoreReactionsClick = onMoreReactionsClick,
- onReadReceiptClick = onReadReceiptClick,
- modifier = Modifier.padding(paddingValues),
- forceJumpToBottomVisibility = forceJumpToBottomVisibility,
- onJoinCallClick = onJoinCallClick,
- )
+ Box(modifier = Modifier.padding(paddingValues)) {
+ val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
+ TimelineView(
+ state = state.timelineState,
+ typingNotificationState = state.typingNotificationState,
+ onUserDataClick = onUserDataClick,
+ onLinkClick = onLinkClick,
+ onMessageClick = onMessageClick,
+ onMessageLongClick = onMessageLongClick,
+ onSwipeToReply = onSwipeToReply,
+ onReactionClick = onReactionClick,
+ onReactionLongClick = onReactionLongClick,
+ onMoreReactionsClick = onMoreReactionsClick,
+ onReadReceiptClick = onReadReceiptClick,
+ forceJumpToBottomVisibility = forceJumpToBottomVisibility,
+ onJoinCallClick = onJoinCallClick,
+ nestedScrollConnection = scrollBehavior.nestedScrollConnection,
+ )
+ AnimatedVisibility(
+ visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ fun focusOnPinnedEvent(eventId: EventId) {
+ state.timelineState.eventSink(
+ TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
+ )
+ }
+ PinnedMessagesBannerView(
+ state = state.pinnedMessagesBannerState,
+ onClick = ::focusOnPinnedEvent,
+ onViewAllClick = onViewAllPinnedMessagesClick,
+ )
+ }
+ }
},
sheetContent = { subcomposing: Boolean ->
MessagesViewComposerBottomSheetContents(
@@ -398,7 +427,7 @@ private fun MessagesViewContent(
},
sheetContentKey = sheetResizeContentKey.intValue,
sheetTonalElevation = 0.dp,
- sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
+ sheetShadowElevation = if (state.composerState.suggestions.isNotEmpty()) 16.dp else 0.dp,
)
}
}
@@ -408,9 +437,9 @@ private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean,
state: MessagesState,
) {
- if (state.userHasPermissionToSendMessage) {
+ if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) {
- MentionSuggestionsPickerView(
+ SuggestionsPickerView(
modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
@@ -422,9 +451,9 @@ private fun MessagesViewComposerBottomSheetContents(
roomId = state.roomId,
roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(),
- memberSuggestions = state.composerState.memberSuggestions,
+ suggestions = state.composerState.suggestions,
onSelectSuggestion = {
- state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
+ state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
}
)
MessageComposerView(
@@ -557,12 +586,13 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
- onPreviewAttachments = {},
onUserDataClick = {},
onLinkClick = {},
+ onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt
new file mode 100644
index 0000000000..25d06add8d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl
+
+/**
+ * Represents the permissions a user has in a room.
+ * It's dependent of the user's power level in the room.
+ */
+data class UserEventPermissions(
+ val canRedactOwn: Boolean,
+ val canRedactOther: Boolean,
+ val canSendMessage: Boolean,
+ val canSendReaction: Boolean,
+ val canPinUnpin: Boolean,
+) {
+ companion object {
+ val DEFAULT = UserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = false
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
index e486c1ae2b..407d18afb7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt
@@ -16,15 +16,13 @@
package io.element.android.features.messages.impl.actionlist
+import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents {
data object Clear : ActionListEvents
data class ComputeForMessage(
val event: TimelineItem.Event,
- val canRedactOwn: Boolean,
- val canRedactOther: Boolean,
- val canSendMessage: Boolean,
- val canSendReaction: Boolean,
+ val userEventPermissions: UserEventPermissions,
) : ActionListEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index d1a19b600d..124f4e911d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -23,25 +23,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
+import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class ActionListPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
+ private val featureFlagsService: FeatureFlagService,
+ private val room: MatrixRoom,
) : Presenter {
@Composable
override fun present(): ActionListState {
@@ -52,17 +63,20 @@ class ActionListPresenter @Inject constructor(
}
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
+ val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
+ val pinnedEventIds by remember {
+ room.roomInfoFlow.map { it.pinnedEventIds }
+ }.collectAsState(initial = persistentListOf())
fun handleEvents(event: ActionListEvents) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event,
- userCanRedactOwn = event.canRedactOwn,
- userCanRedactOther = event.canRedactOther,
- userCanSendMessage = event.canSendMessage,
- userCanSendReaction = event.canSendReaction,
+ usersEventPermissions = event.userEventPermissions,
isDeveloperModeEnabled = isDeveloperModeEnabled,
+ isPinnedEventsEnabled = isPinnedEventsEnabled,
+ pinnedEventIds = pinnedEventIds,
target = target,
)
}
@@ -76,136 +90,22 @@ class ActionListPresenter @Inject constructor(
private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event,
- userCanRedactOwn: Boolean,
- userCanRedactOther: Boolean,
- userCanSendMessage: Boolean,
- userCanSendReaction: Boolean,
+ usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
+ isPinnedEventsEnabled: Boolean,
+ pinnedEventIds: ImmutableList,
target: MutableState
) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
- val canRedact = timelineItem.isMine && userCanRedactOwn || !timelineItem.isMine && userCanRedactOther
- val actions =
- when (timelineItem.content) {
- is TimelineItemCallNotifyContent -> {
- if (isDeveloperModeEnabled) {
- listOf(TimelineItemAction.ViewSource)
- } else {
- emptyList()
- }
- }
- is TimelineItemRedactedContent -> {
- if (isDeveloperModeEnabled) {
- listOf(TimelineItemAction.ViewSource)
- } else {
- emptyList()
- }
- }
- is TimelineItemStateContent -> {
- buildList {
- add(TimelineItemAction.Copy)
- if (timelineItem.isRemote) {
- add(TimelineItemAction.CopyLink)
- }
- if (isDeveloperModeEnabled) {
- add(TimelineItemAction.ViewSource)
- }
- }
- }
- is TimelineItemPollContent -> {
- val canEndPoll = timelineItem.isRemote &&
- !timelineItem.content.isEnded &&
- (timelineItem.isMine || canRedact)
- buildList {
- if (timelineItem.isRemote) {
- // Can only reply or forward messages already uploaded to the server
- add(TimelineItemAction.Reply)
- }
- if (timelineItem.isRemote && timelineItem.isEditable) {
- add(TimelineItemAction.Edit)
- }
- if (canEndPoll) {
- add(TimelineItemAction.EndPoll)
- }
- if (timelineItem.content.canBeCopied()) {
- add(TimelineItemAction.Copy)
- }
- if (timelineItem.isRemote) {
- add(TimelineItemAction.CopyLink)
- }
- if (isDeveloperModeEnabled) {
- add(TimelineItemAction.ViewSource)
- }
- if (!timelineItem.isMine) {
- add(TimelineItemAction.ReportContent)
- }
- if (canRedact) {
- add(TimelineItemAction.Redact)
- }
- }
- }
- is TimelineItemVoiceContent -> {
- buildList {
- if (timelineItem.isRemote) {
- add(TimelineItemAction.Reply)
- add(TimelineItemAction.Forward)
- add(TimelineItemAction.CopyLink)
- }
- if (isDeveloperModeEnabled) {
- add(TimelineItemAction.ViewSource)
- }
- if (!timelineItem.isMine) {
- add(TimelineItemAction.ReportContent)
- }
- if (canRedact) {
- add(TimelineItemAction.Redact)
- }
- }
- }
- is TimelineItemLegacyCallInviteContent -> {
- buildList {
- if (isDeveloperModeEnabled) {
- add(TimelineItemAction.ViewSource)
- }
- }
- }
- else -> buildList {
- if (timelineItem.isRemote) {
- // Can only reply or forward messages already uploaded to the server
- if (userCanSendMessage) {
- if (timelineItem.isThreaded) {
- add(TimelineItemAction.ReplyInThread)
- } else {
- add(TimelineItemAction.Reply)
- }
- }
- // Stickers can't be forwarded (yet) so we don't show the option
- // See https://github.com/element-hq/element-x-android/issues/2161
- if (!timelineItem.isSticker) {
- add(TimelineItemAction.Forward)
- }
- }
- if (timelineItem.isEditable) {
- add(TimelineItemAction.Edit)
- }
- if (timelineItem.content.canBeCopied()) {
- add(TimelineItemAction.Copy)
- }
- if (timelineItem.isRemote) {
- add(TimelineItemAction.CopyLink)
- }
- if (isDeveloperModeEnabled) {
- add(TimelineItemAction.ViewSource)
- }
- if (!timelineItem.isMine) {
- add(TimelineItemAction.ReportContent)
- }
- if (canRedact) {
- add(TimelineItemAction.Redact)
- }
- }
- }
- val displayEmojiReactions = userCanSendReaction &&
+
+ val actions = buildActions(
+ timelineItem = timelineItem,
+ usersEventPermissions = usersEventPermissions,
+ isDeveloperModeEnabled = isDeveloperModeEnabled,
+ isPinnedEventsEnabled = isPinnedEventsEnabled,
+ isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
+ )
+ val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.isRemote &&
timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) {
@@ -219,3 +119,71 @@ class ActionListPresenter @Inject constructor(
}
}
}
+
+private fun buildActions(
+ timelineItem: TimelineItem.Event,
+ usersEventPermissions: UserEventPermissions,
+ isDeveloperModeEnabled: Boolean,
+ isPinnedEventsEnabled: Boolean,
+ isEventPinned: Boolean,
+): List {
+ val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
+ return buildList {
+ if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
+ if (timelineItem.isThreaded) {
+ add(TimelineItemAction.ReplyInThread)
+ } else {
+ add(TimelineItemAction.Reply)
+ }
+ }
+ if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
+ add(TimelineItemAction.Forward)
+ }
+ if (timelineItem.isEditable) {
+ add(TimelineItemAction.Edit)
+ }
+ if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
+ add(TimelineItemAction.EndPoll)
+ }
+ val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote
+ if (canPinUnpin) {
+ if (isEventPinned) {
+ add(TimelineItemAction.Unpin)
+ } else {
+ add(TimelineItemAction.Pin)
+ }
+ }
+ if (timelineItem.content.canBeCopied()) {
+ add(TimelineItemAction.Copy)
+ }
+ if (timelineItem.isRemote) {
+ add(TimelineItemAction.CopyLink)
+ }
+ if (isDeveloperModeEnabled) {
+ add(TimelineItemAction.ViewSource)
+ }
+ if (!timelineItem.isMine) {
+ add(TimelineItemAction.ReportContent)
+ }
+ if (canRedact) {
+ add(TimelineItemAction.Redact)
+ }
+ }.postFilter(timelineItem.content)
+}
+
+/**
+ * Post filter the actions based on the content of the event.
+ */
+private fun List.postFilter(content: TimelineItemEventContent): List {
+ return filter { action ->
+ when (content) {
+ is TimelineItemCallNotifyContent,
+ is TimelineItemLegacyCallInviteContent,
+ is TimelineItemStateContent,
+ is TimelineItemRedactedContent -> {
+ action == TimelineItemAction.ViewSource
+ }
+ else -> true
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index 48c44e38c9..41c5074947 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -121,6 +122,16 @@ open class ActionListStateProvider : PreviewParameterProvider {
actions = aTimelineItemPollActionList(),
),
),
+ anActionListState().copy(
+ target = ActionListState.Target.Success(
+ event = aTimelineItemEvent().copy(
+ reactionsState = reactionsState,
+ messageShield = MessageShield.UnknownDevice(isCritical = true)
+ ),
+ displayEmojiReactions = true,
+ actions = aTimelineItemActionList(),
+ )
+ ),
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
index ed6fffb90f..d928219fe5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
@@ -55,6 +55,7 @@ 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.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@@ -181,7 +182,14 @@ private fun SheetContent(
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
- Spacer(modifier = Modifier.height(14.dp))
+ if (target.event.messageShield != null) {
+ MessageShieldView(
+ shield = target.event.messageShield,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ )
+ } else {
+ Spacer(modifier = Modifier.height(14.dp))
+ }
HorizontalDivider()
}
}
@@ -218,6 +226,7 @@ private fun SheetContent(
}
}
+@Suppress("MultipleEmitters") // False positive
@Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
val content: @Composable () -> Unit
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index f61e6197c2..a650dc88eb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -39,4 +39,8 @@ sealed class TimelineItemAction(
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
+ data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
+
+ // TODO use the Unpin compound icon when available.
+ data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_pin)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
new file mode 100644
index 0000000000..dba98edf3d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.RoomScope
+
+@ContributesTo(RoomScope::class)
+@Module
+interface MessagesModule {
+ @Binds
+ fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
index 1f6ae7c7f4..d5f3429450 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
@@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import androidx.compose.runtime.Immutable
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@@ -44,6 +44,6 @@ sealed interface MessageComposerEvents {
data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
- data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents
+ data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvents
data object SaveDraft : MessageComposerEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index d0e50d114c..8f01f15542 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -40,7 +40,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.attachments.Attachment
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.mentions.MentionSuggestionsProcessor
+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.Presenter
@@ -55,8 +55,8 @@ 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.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
@@ -72,7 +72,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
@@ -117,6 +117,7 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
+ private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
@@ -125,6 +126,7 @@ class MessageComposerPresenter @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
+ private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
@@ -149,8 +151,10 @@ class MessageComposerPresenter @Inject constructor(
}
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
var isMentionsEnabled by remember { mutableStateOf(false) }
+ var isRoomAliasSuggestionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
+ isRoomAliasSuggestionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomAliasSuggestions)
}
val cameraPermissionState = cameraPermissionPresenter.present()
@@ -189,6 +193,8 @@ class MessageComposerPresenter @Inject constructor(
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
+ val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
+
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> {
@@ -212,7 +218,7 @@ class MessageComposerPresenter @Inject constructor(
}
}
- val memberSuggestions = remember { mutableStateListOf() }
+ val suggestions = remember { mutableStateListOf() }
LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = room.sessionId
@@ -228,15 +234,16 @@ class MessageComposerPresenter @Inject constructor(
val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() }
merge(mentionStartTrigger, mentionCompletionTrigger)
.combine(room.membersStateFlow) { suggestion, roomMembersState ->
- memberSuggestions.clear()
- val result = MentionSuggestionsProcessor.process(
+ suggestions.clear()
+ val result = suggestionsProcessor.process(
suggestion = suggestion,
roomMembersState = roomMembersState,
+ roomAliasSuggestions = if (isRoomAliasSuggestionsEnabled) roomAliasSuggestions else emptyList(),
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
)
if (result.isNotEmpty()) {
- memberSuggestions.addAll(result)
+ suggestions.addAll(result)
}
}
.collect()
@@ -362,22 +369,27 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SuggestionReceived -> {
suggestionSearchTrigger.value = event.suggestion
}
- is MessageComposerEvents.InsertMention -> {
+ is MessageComposerEvents.InsertSuggestion -> {
localCoroutineScope.launch {
if (showTextFormatting) {
- when (val mention = event.mention) {
- is ResolvedMentionSuggestion.AtRoom -> {
+ when (val suggestion = event.resolvedSuggestion) {
+ is ResolvedSuggestion.AtRoom -> {
richTextEditorState.insertAtRoomMentionAtSuggestion()
}
- is ResolvedMentionSuggestion.Member -> {
- val text = mention.roomMember.userId.value
- val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
+ is ResolvedSuggestion.Member -> {
+ val text = suggestion.roomMember.userId.value
+ val link = permalinkBuilder.permalinkForUser(suggestion.roomMember.userId).getOrNull() ?: return@launch
+ richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
+ }
+ is ResolvedSuggestion.Alias -> {
+ val text = suggestion.roomAlias.value
+ val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
}
- } else if (markdownTextEditorState.currentMentionSuggestion != null) {
- markdownTextEditorState.insertMention(
- mention = event.mention,
+ } else if (markdownTextEditorState.currentSuggestion != null) {
+ markdownTextEditorState.insertSuggestion(
+ resolvedSuggestion = event.resolvedSuggestion,
mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder,
)
@@ -417,7 +429,7 @@ class MessageComposerPresenter @Inject constructor(
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
- memberSuggestions = memberSuggestions.toPersistentList(),
+ suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
)
@@ -432,17 +444,21 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {
- is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = message.mentions)
+ is MessageComposerMode.Normal -> room.sendMessage(
+ body = message.markdown,
+ htmlBody = message.html,
+ intentionalMentions = message.intentionalMentions
+ )
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
timelineController.invokeOnCurrentTimeline {
// First try to edit the message in the current timeline
- editMessage(eventId, transactionId, message.markdown, message.html, message.mentions)
+ editMessage(eventId, transactionId, message.markdown, message.html, message.intentionalMentions)
.onFailure { cause ->
if (cause is TimelineException.EventNotFound && eventId != null) {
// if the event is not found in the timeline, try to edit the message directly
- room.editMessage(eventId, message.markdown, message.html, message.mentions)
+ room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions)
}
}
}
@@ -450,7 +466,7 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
- replyMessage(capturedMode.eventId, message.markdown, message.html, message.mentions)
+ replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
}
}
}
@@ -623,15 +639,15 @@ class MessageComposerPresenter @Inject constructor(
?.let { state ->
buildList {
if (state.hasAtRoomMention) {
- add(Mention.AtRoom)
+ add(IntentionalMention.Room)
}
for (userId in state.userIds) {
- add(Mention.User(UserId(userId)))
+ add(IntentionalMention.User(UserId(userId)))
}
}
}
.orEmpty()
- Message(html = html, markdown = markdown, mentions = mentions)
+ Message(html = html, markdown = markdown, intentionalMentions = mentions)
} else {
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
val mentions = if (withMentions) {
@@ -639,7 +655,7 @@ class MessageComposerPresenter @Inject constructor(
} else {
emptyList()
}
- Message(html = null, markdown = markdown, mentions = mentions)
+ Message(html = null, markdown = markdown, intentionalMentions = mentions)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index 332a9e75f8..3698cdedbc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay
@@ -35,7 +35,7 @@ data class MessageComposerState(
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
- val memberSuggestions: ImmutableList,
+ val suggestions: ImmutableList,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index 824f87bb8e..b30074bb78 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.aRichTextEditorState
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay
@@ -41,7 +41,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
- memberSuggestions: ImmutableList = persistentListOf(),
+ suggestions: ImmutableList = persistentListOf(),
) = MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen,
@@ -51,7 +51,7 @@ fun aMessageComposerState(
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
- memberSuggestions = memberSuggestions,
+ suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = {},
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt
new file mode 100644
index 0000000000..860b1147cd
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.messagecomposer
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+data class RoomAliasSuggestion(
+ val roomAlias: RoomAlias,
+ val roomSummary: RoomSummary,
+)
+
+interface RoomAliasSuggestionsDataSource {
+ fun getAllRoomAliasSuggestions(): Flow>
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultRoomAliasSuggestionsDataSource @Inject constructor(
+ private val roomListService: RoomListService,
+) : RoomAliasSuggestionsDataSource {
+ override fun getAllRoomAliasSuggestions(): Flow> {
+ return roomListService
+ .allRooms
+ .filteredSummaries
+ .map { roomSummaries ->
+ roomSummaries
+ .mapNotNull { roomSummary ->
+ roomSummary.canonicalAlias?.let { roomAlias ->
+ RoomAliasSuggestion(
+ roomAlias = roomAlias,
+ roomSummary = roomSummary,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt
similarity index 64%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt
index 48c626680e..c52ef4771c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.features.messages.impl.mentions
+package io.element.android.features.messages.impl.messagecomposer.suggestions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -39,38 +40,42 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.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.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
-fun MentionSuggestionsPickerView(
+fun SuggestionsPickerView(
roomId: RoomId,
roomName: String?,
roomAvatarData: AvatarData?,
- memberSuggestions: ImmutableList,
- onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit,
+ suggestions: ImmutableList,
+ onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
) {
items(
- memberSuggestions,
+ suggestions,
key = { suggestion ->
when (suggestion) {
- is ResolvedMentionSuggestion.AtRoom -> "@room"
- is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value
+ is ResolvedSuggestion.AtRoom -> "@room"
+ is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
+ is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
}
}
) {
Column(modifier = Modifier.fillParentMaxWidth()) {
- RoomMemberSuggestionItemView(
- memberSuggestion = it,
+ SuggestionItemView(
+ suggestion = it,
roomId = roomId.value,
roomName = roomName,
roomAvatar = roomAvatarData,
@@ -84,33 +89,33 @@ fun MentionSuggestionsPickerView(
}
@Composable
-private fun RoomMemberSuggestionItemView(
- memberSuggestion: ResolvedMentionSuggestion,
+private fun SuggestionItemView(
+ suggestion: ResolvedSuggestion,
roomId: String,
roomName: String?,
roomAvatar: AvatarData?,
- onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit,
+ onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
- Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- val avatarData = when (memberSuggestion) {
- is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = AvatarSize.Suggestion)
- ?: AvatarData(roomId, roomName, null, AvatarSize.Suggestion)
- is ResolvedMentionSuggestion.Member -> AvatarData(
- id = memberSuggestion.roomMember.userId.value,
- name = memberSuggestion.roomMember.displayName,
- url = memberSuggestion.roomMember.avatarUrl,
- size = AvatarSize.Suggestion,
- )
+ Row(
+ modifier = modifier.clickable { onSelectSuggestion(suggestion) },
+ horizontalArrangement = Arrangement.spacedBy(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.roomSummary.getAvatarData(avatarSize)
}
- val title = when (memberSuggestion) {
- is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
- is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName
+ 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.roomSummary.name
}
-
- val subtitle = when (memberSuggestion) {
- is ResolvedMentionSuggestion.AtRoom -> "@room"
- is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
+ val subtitle = when (suggestion) {
+ is ResolvedSuggestion.AtRoom -> "@room"
+ is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
+ is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
}
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@@ -142,7 +147,7 @@ private fun RoomMemberSuggestionItemView(
@PreviewsDayNight
@Composable
-internal fun MentionSuggestionsPickerViewPreview() {
+internal fun SuggestionsPickerViewPreview() {
ElementPreview {
val roomMember = RoomMember(
userId = UserId("@alice:server.org"),
@@ -155,14 +160,24 @@ internal fun MentionSuggestionsPickerViewPreview() {
isIgnored = false,
role = RoomMember.Role.USER,
)
- MentionSuggestionsPickerView(
+ val anAlias = remember { RoomAlias("#room:domain.org") }
+ val roomSummaryDetails = remember {
+ aRoomSummaryDetails(
+ name = "My room",
+ )
+ }
+ SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"),
roomName = "Room",
roomAvatarData = null,
- memberSuggestions = persistentListOf(
- ResolvedMentionSuggestion.AtRoom,
- ResolvedMentionSuggestion.Member(roomMember),
- ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
+ suggestions = persistentListOf(
+ ResolvedSuggestion.AtRoom,
+ ResolvedSuggestion.Member(roomMember),
+ ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
+ ResolvedSuggestion.Alias(
+ anAlias,
+ roomSummaryDetails,
+ )
),
onSelectSuggestion = {}
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt
similarity index 62%
rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt
index f0e89c1148..171f4a2713 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt
@@ -14,63 +14,63 @@
* limitations under the License.
*/
-package io.element.android.features.messages.impl.mentions
+package io.element.android.features.messages.impl.messagecomposer.suggestions
+import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
import io.element.android.libraries.core.data.filterUpTo
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
+import javax.inject.Inject
/**
- * This class is responsible for processing mention suggestions when `@`, `/` or `#` are type in the composer.
+ * This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
*/
-object MentionSuggestionsProcessor {
- // We don't want to retrieve thousands of members
- private const val MAX_BATCH_ITEMS = 100
-
+class SuggestionsProcessor @Inject constructor() {
/**
- * Process the mention suggestions.
+ * Process the suggestion.
* @param suggestion The current suggestion input
* @param roomMembersState The room members state, it contains the current users in the room
+ * @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
- * @return The list of mentions to display
+ * @return The list of suggestions to display
*/
suspend fun process(
suggestion: Suggestion?,
roomMembersState: MatrixRoomMembersState,
+ roomAliasSuggestions: List,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
- ): List {
- val members = roomMembersState.roomMembers()
- return when {
- members.isNullOrEmpty() || suggestion == null -> {
+ ): List {
+ suggestion ?: return emptyList()
+ return when (suggestion.type) {
+ SuggestionType.Mention -> {
+ // Replace suggestions
+ val members = roomMembersState.roomMembers()
+ val matchingMembers = getMemberSuggestions(
+ query = suggestion.text,
+ roomMembers = members,
+ currentUserId = currentUserId,
+ canSendRoomMention = canSendRoomMention()
+ )
+ matchingMembers
+ }
+ SuggestionType.Room -> {
+ roomAliasSuggestions
+ .filter { it.roomAlias.value.contains(suggestion.text, ignoreCase = true) }
+ .map { ResolvedSuggestion.Alias(it.roomAlias, it.roomSummary) }
+ }
+ SuggestionType.Command,
+ is SuggestionType.Custom -> {
// Clear suggestions
emptyList()
}
- else -> {
- when (suggestion.type) {
- SuggestionType.Mention -> {
- // Replace suggestions
- val matchingMembers = getMemberSuggestions(
- query = suggestion.text,
- roomMembers = members,
- currentUserId = currentUserId,
- canSendRoomMention = canSendRoomMention()
- )
- matchingMembers
- }
- else -> {
- // Clear suggestions
- emptyList()
- }
- }
- }
}
}
@@ -79,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List?,
currentUserId: UserId,
canSendRoomMention: Boolean,
- ): List {
+ ): List {
return if (roomMembers.isNullOrEmpty()) {
emptyList()
} else {
@@ -97,13 +97,18 @@ object MentionSuggestionsProcessor {
.filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
}
- .map(ResolvedMentionSuggestion::Member)
+ .map(ResolvedSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) {
- listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers
+ listOf(ResolvedSuggestion.AtRoom) + matchingMembers
} else {
matchingMembers
}
}
}
+
+ companion object {
+ // We don't want to retrieve thousands of members
+ private const val MAX_BATCH_ITEMS = 100
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/IsPinnedMessagesFeatureEnabled.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/IsPinnedMessagesFeatureEnabled.kt
new file mode 100644
index 0000000000..5ef5e2c793
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/IsPinnedMessagesFeatureEnabled.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+
+fun interface IsPinnedMessagesFeatureEnabled {
+ @Composable
+ operator fun invoke(): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor(
+ private val featureFlagService: FeatureFlagService,
+) : IsPinnedMessagesFeatureEnabled {
+ @Composable
+ override operator fun invoke(): Boolean {
+ var isFeatureEnabled by rememberSaveable {
+ mutableStateOf(false)
+ }
+ LaunchedEffect(Unit) {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents)
+ .onEach { isFeatureEnabled = it }
+ .launchIn(this)
+ }
+ return isFeatureEnabled
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt
new file mode 100644
index 0000000000..792db7e3da
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+sealed interface PinnedMessagesBannerEvents {
+ data object MoveToNextPinned : PinnedMessagesBannerEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt
new file mode 100644
index 0000000000..19dce8a4a0
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.ui.text.AnnotatedString
+import io.element.android.libraries.matrix.api.core.EventId
+
+data class PinnedMessagesBannerItem(
+ val eventId: EventId,
+ val formatted: AnnotatedString,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt
new file mode 100644
index 0000000000..95c13e1f64
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.ui.text.AnnotatedString
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class PinnedMessagesBannerItemFactory @Inject constructor(
+ private val coroutineDispatchers: CoroutineDispatchers,
+ private val formatter: PinnedMessagesBannerFormatter,
+) {
+ suspend fun create(timelineItem: MatrixTimelineItem): PinnedMessagesBannerItem? = withContext(coroutineDispatchers.computation) {
+ when (timelineItem) {
+ is MatrixTimelineItem.Event -> {
+ val eventId = timelineItem.eventId ?: return@withContext null
+ val formatted = formatter.format(timelineItem.event)
+ PinnedMessagesBannerItem(
+ eventId = eventId,
+ formatted = if (formatted is AnnotatedString) {
+ formatted
+ } else {
+ AnnotatedString(formatted.toString())
+ },
+ )
+ }
+ else -> null
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt
new file mode 100644
index 0000000000..13c45e0092
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+class PinnedMessagesBannerPresenter @Inject constructor(
+ private val room: MatrixRoom,
+ private val itemFactory: PinnedMessagesBannerItemFactory,
+ private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled,
+ private val networkMonitor: NetworkMonitor,
+) : Presenter {
+ private val pinnedItems = mutableStateOf>(persistentListOf())
+
+ @Composable
+ override fun present(): PinnedMessagesBannerState {
+ val isFeatureEnabled = isFeatureEnabled()
+ val expectedPinnedMessagesCount by remember {
+ room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
+ }.collectAsState(initial = 0)
+
+ var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) }
+ var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
+
+ PinnedMessagesBannerItemsEffect(
+ isFeatureEnabled = isFeatureEnabled,
+ onItemsChange = { newItems ->
+ val pinnedMessageCount = newItems.size
+ if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
+ currentPinnedMessageIndex = pinnedMessageCount - 1
+ }
+ pinnedItems.value = newItems
+ },
+ onTimelineFail = { hasTimelineFailed ->
+ hasTimelineFailedToLoad = hasTimelineFailed
+ }
+ )
+
+ fun handleEvent(event: PinnedMessagesBannerEvents) {
+ when (event) {
+ is PinnedMessagesBannerEvents.MoveToNextPinned -> {
+ currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size)
+ }
+ }
+ }
+
+ return pinnedMessagesBannerState(
+ isFeatureEnabled = isFeatureEnabled,
+ hasTimelineFailed = hasTimelineFailedToLoad,
+ expectedPinnedMessagesCount = expectedPinnedMessagesCount,
+ pinnedItems = pinnedItems.value,
+ currentPinnedMessageIndex = currentPinnedMessageIndex,
+ eventSink = ::handleEvent
+ )
+ }
+
+ @Composable
+ private fun pinnedMessagesBannerState(
+ isFeatureEnabled: Boolean,
+ hasTimelineFailed: Boolean,
+ expectedPinnedMessagesCount: Int,
+ pinnedItems: ImmutableList,
+ currentPinnedMessageIndex: Int,
+ eventSink: (PinnedMessagesBannerEvents) -> Unit
+ ): PinnedMessagesBannerState {
+ val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
+ return when {
+ !isFeatureEnabled -> PinnedMessagesBannerState.Hidden
+ hasTimelineFailed -> PinnedMessagesBannerState.Hidden
+ currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded(
+ currentPinnedMessage = currentPinnedMessage,
+ currentPinnedMessageIndex = currentPinnedMessageIndex,
+ loadedPinnedMessagesCount = pinnedItems.size,
+ eventSink = eventSink
+ )
+ expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
+ else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ @Composable
+ private fun PinnedMessagesBannerItemsEffect(
+ isFeatureEnabled: Boolean,
+ onItemsChange: (ImmutableList) -> Unit,
+ onTimelineFail: (Boolean) -> Unit,
+ ) {
+ val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
+ val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
+ val networkStatus by networkMonitor.connectivity.collectAsState()
+
+ LaunchedEffect(isFeatureEnabled, networkStatus) {
+ if (!isFeatureEnabled) {
+ updatedOnItemsChange(persistentListOf())
+ return@LaunchedEffect
+ }
+ val pinnedEventsTimeline = room.pinnedEventsTimeline()
+ .onFailure { updatedOnTimelineFail(true) }
+ .onSuccess { updatedOnTimelineFail(false) }
+ .getOrNull()
+ ?: return@LaunchedEffect
+
+ pinnedEventsTimeline.timelineItems
+ .debounce(300.milliseconds)
+ .map { timelineItems ->
+ timelineItems.mapNotNull { timelineItem ->
+ itemFactory.create(timelineItem)
+ }.toImmutableList()
+ }
+ .onEach { newItems ->
+ updatedOnItemsChange(newItems)
+ }
+ .onCompletion {
+ pinnedEventsTimeline.close()
+ }
+ .launchIn(this)
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt
new file mode 100644
index 0000000000..a686ada33c
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import io.element.android.libraries.designsystem.text.toAnnotatedString
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Immutable
+sealed interface PinnedMessagesBannerState {
+ data object Hidden : PinnedMessagesBannerState
+ sealed interface Visible : PinnedMessagesBannerState
+ data class Loading(val expectedPinnedMessagesCount: Int) : Visible
+ data class Loaded(
+ val currentPinnedMessage: PinnedMessagesBannerItem,
+ val currentPinnedMessageIndex: Int,
+ val loadedPinnedMessagesCount: Int,
+ val eventSink: (PinnedMessagesBannerEvents) -> Unit
+ ) : Visible
+
+ fun pinnedMessagesCount() = when (this) {
+ is Hidden -> 0
+ is Loading -> expectedPinnedMessagesCount
+ is Loaded -> loadedPinnedMessagesCount
+ }
+
+ fun currentPinnedMessageIndex() = when (this) {
+ is Hidden -> 0
+ is Loading -> expectedPinnedMessagesCount - 1
+ is Loaded -> currentPinnedMessageIndex
+ }
+
+ @Composable
+ fun formattedMessage() = when (this) {
+ is Hidden -> AnnotatedString("")
+ is Loading -> stringResource(id = CommonStrings.screen_room_pinned_banner_loading_description).toAnnotatedString()
+ is Loaded -> currentPinnedMessage.formatted
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt
new file mode 100644
index 0000000000..e9d0a27c16
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.EventId
+import kotlin.random.Random
+
+internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aHiddenPinnedMessagesBannerState(),
+ aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1),
+ aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 4),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 2, currentPinnedMessageIndex = 0),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 0),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 1),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 2),
+ aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 3),
+ )
+}
+
+internal fun aHiddenPinnedMessagesBannerState() = PinnedMessagesBannerState.Hidden
+
+internal fun aLoadingPinnedMessagesBannerState(
+ knownPinnedMessagesCount: Int = 4
+) = PinnedMessagesBannerState.Loading(
+ expectedPinnedMessagesCount = knownPinnedMessagesCount
+)
+
+internal fun aLoadedPinnedMessagesBannerState(
+ currentPinnedMessageIndex: Int = 0,
+ knownPinnedMessagesCount: Int = 1,
+ currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
+ eventId = EventId("\$" + Random.nextInt().toString()),
+ formatted = AnnotatedString("This is a pinned message")
+ ),
+ eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
+) = PinnedMessagesBannerState.Loaded(
+ currentPinnedMessage = currentPinnedMessage,
+ currentPinnedMessageIndex = currentPinnedMessageIndex,
+ loadedPinnedMessagesCount = knownPinnedMessagesCount,
+ eventSink = eventSink
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
new file mode 100644
index 0000000000..d466f1c20f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+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.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder
+import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun PinnedMessagesBannerView(
+ state: PinnedMessagesBannerState,
+ onClick: (EventId) -> Unit,
+ onViewAllClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (state) {
+ PinnedMessagesBannerState.Hidden -> Unit
+ is PinnedMessagesBannerState.Visible -> {
+ PinnedMessagesBannerRow(
+ state = state,
+ onClick = onClick,
+ onViewAllClick = onViewAllClick,
+ modifier = modifier,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PinnedMessagesBannerRow(
+ state: PinnedMessagesBannerState,
+ onClick: (EventId) -> Unit,
+ onViewAllClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
+ Row(
+ modifier = modifier
+ .background(color = ElementTheme.colors.bgCanvasDefault)
+ .fillMaxWidth()
+ .drawBorder(borderColor)
+ .heightIn(min = 64.dp)
+ .clickable {
+ if (state is PinnedMessagesBannerState.Loaded) {
+ onClick(state.currentPinnedMessage.eventId)
+ state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
+ }
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = spacedBy(10.dp)
+ ) {
+ Spacer(modifier = Modifier.width(16.dp))
+ PinIndicators(
+ pinIndex = state.currentPinnedMessageIndex(),
+ pinsCount = state.pinnedMessagesCount(),
+ modifier = Modifier.heightIn(max = 40.dp)
+ )
+ Icon(
+ imageVector = CompoundIcons.PinSolid(),
+ contentDescription = null,
+ tint = ElementTheme.materialColors.secondary,
+ modifier = Modifier.size(20.dp)
+ )
+ PinnedMessageItem(
+ index = state.currentPinnedMessageIndex(),
+ totalCount = state.pinnedMessagesCount(),
+ message = state.formattedMessage(),
+ modifier = Modifier.weight(1f)
+ )
+ ViewAllButton(state, onViewAllClick)
+ }
+}
+
+@Composable
+private fun ViewAllButton(
+ state: PinnedMessagesBannerState,
+ onViewAllClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val text = if (state is PinnedMessagesBannerState.Loaded) {
+ stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title)
+ } else {
+ ""
+ }
+ TextButton(
+ text = text,
+ showProgress = state is PinnedMessagesBannerState.Loading,
+ onClick = onViewAllClick,
+ modifier = modifier,
+ )
+}
+
+private fun Modifier.drawBorder(borderColor: Color): Modifier {
+ return this
+ .drawBehind {
+ val strokeWidth = 0.5.dp.toPx()
+ val y = size.height - strokeWidth / 2
+ drawLine(
+ borderColor,
+ Offset(0f, y),
+ Offset(size.width, y),
+ strokeWidth
+ )
+ drawLine(
+ borderColor,
+ Offset(0f, 0f),
+ Offset(size.width, 0f),
+ strokeWidth
+ )
+ }
+ .shadow(elevation = 5.dp, spotColor = Color.Transparent)
+}
+
+@Composable
+private fun PinIndicators(
+ pinIndex: Int,
+ pinsCount: Int,
+ modifier: Modifier = Modifier,
+) {
+ val indicatorHeight = remember(pinsCount) {
+ when (pinsCount) {
+ 0 -> 0
+ 1 -> 32
+ 2 -> 18
+ else -> 11
+ }
+ }
+ val lazyListState = rememberLazyListState()
+ LaunchedEffect(pinIndex) {
+ val viewportSize = lazyListState.layoutInfo.viewportSize
+ lazyListState.animateScrollToItem(
+ pinIndex,
+ indicatorHeight / 2 - viewportSize.height / 2
+ )
+ }
+ LazyColumn(
+ modifier = modifier,
+ state = lazyListState,
+ verticalArrangement = spacedBy(2.dp),
+ userScrollEnabled = false,
+ ) {
+ items(pinsCount) { index ->
+ Box(
+ modifier = Modifier
+ .width(2.dp)
+ .height(indicatorHeight.dp)
+ .background(
+ color = if (index == pinIndex) {
+ ElementTheme.colors.iconAccentPrimary
+ } else {
+ ElementTheme.colors.pinnedMessageBannerIndicator
+ }
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun PinnedMessageItem(
+ index: Int,
+ totalCount: Int,
+ message: AnnotatedString?,
+ modifier: Modifier = Modifier,
+) {
+ val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount)
+ val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage)
+ Column(modifier = modifier) {
+ AnimatedVisibility(totalCount > 1) {
+ Text(
+ text = annotatedTextWithBold(
+ text = fullCountMessage,
+ boldText = countMessage,
+ ),
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = ElementTheme.colors.textActionAccent,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (message != null) {
+ Text(
+ text = message,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Stable
+internal interface PinnedMessagesBannerViewScrollBehavior {
+ val isVisible: Boolean
+ val nestedScrollConnection: NestedScrollConnection
+}
+
+internal object PinnedMessagesBannerViewDefaults {
+ @Composable
+ fun rememberExitOnScrollBehavior(): PinnedMessagesBannerViewScrollBehavior = remember {
+ ExitOnScrollBehavior()
+ }
+}
+
+private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior {
+ override var isVisible by mutableStateOf(true)
+ override val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ if (available.y < -1) {
+ isVisible = true
+ }
+ if (available.y > 1) {
+ isVisible = false
+ }
+ return Offset.Zero
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
+ PinnedMessagesBannerView(
+ state = state,
+ onClick = {},
+ onViewAllClick = {},
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
index 24007530e6..589ca8c6c8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt
@@ -18,14 +18,19 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
+import kotlin.time.Duration
sealed interface TimelineEvents {
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
- data class FocusOnEvent(val eventId: EventId) : TimelineEvents
+ data class FocusOnEvent(val eventId: EventId, val debounce: Duration = Duration.ZERO) : TimelineEvents
data object ClearFocusRequestState : TimelineEvents
data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents
+ data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
+ data object HideShieldDialog : TimelineEvents
+
/**
* Events coming from a timeline item.
*/
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index f5903f036b..c7e3dc6e98 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -45,17 +45,21 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L
+
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemIndexer: TimelineItemIndexer,
@@ -94,6 +98,7 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
+ val messageShield: MutableState = remember { mutableStateOf(null) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
@@ -136,13 +141,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId)
}
- is TimelineEvents.FocusOnEvent -> localScope.launch {
- if (timelineItemIndexer.isKnown(event.eventId)) {
- val index = timelineItemIndexer.indexOf(event.eventId)
- focusRequestState.value = FocusRequestState.Success(eventId = event.eventId, index = index)
- } else {
- focusRequestState.value = FocusRequestState.Loading(eventId = event.eventId)
- }
+ is TimelineEvents.FocusOnEvent -> {
+ focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
}
is TimelineEvents.OnFocusEventRender -> {
focusRequestState.value = focusRequestState.value.onFocusEventRender()
@@ -153,22 +153,35 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
}
+ TimelineEvents.HideShieldDialog -> messageShield.value = null
+ is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
}
}
LaunchedEffect(focusRequestState.value) {
- val currentFocusRequestState = focusRequestState.value
- if (currentFocusRequestState is FocusRequestState.Loading) {
- val eventId = currentFocusRequestState.eventId
- timelineController.focusOnEvent(eventId)
- .fold(
- onSuccess = {
- focusRequestState.value = FocusRequestState.Success(eventId = eventId)
- },
- onFailure = {
- focusRequestState.value = FocusRequestState.Failure(throwable = it)
- }
- )
+ when (val currentFocusRequestState = focusRequestState.value) {
+ is FocusRequestState.Requested -> {
+ delay(currentFocusRequestState.debounce)
+ if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
+ val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
+ focusRequestState.value = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
+ } else {
+ focusRequestState.value = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
+ }
+ }
+ is FocusRequestState.Loading -> {
+ val eventId = currentFocusRequestState.eventId
+ timelineController.focusOnEvent(eventId)
+ .fold(
+ onSuccess = {
+ focusRequestState.value = FocusRequestState.Success(eventId = eventId)
+ },
+ onFailure = {
+ focusRequestState.value = FocusRequestState.Failure(throwable = it)
+ }
+ )
+ }
+ else -> Unit
}
}
@@ -217,6 +230,7 @@ class TimelinePresenter @AssistedInject constructor(
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState.value,
+ messageShield = messageShield.value,
eventSink = { handleEvents(it) }
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index eb8622d0bc..74f8fda0b4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -20,7 +20,9 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
+import kotlin.time.Duration
@Immutable
data class TimelineState(
@@ -30,6 +32,8 @@ data class TimelineState(
val newEventState: NewEventState,
val isLive: Boolean,
val focusRequestState: FocusRequestState,
+ // If not null, info will be rendered in a dialog
+ val messageShield: MessageShield?,
val eventSink: (TimelineEvents) -> Unit,
) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
@@ -39,6 +43,7 @@ data class TimelineState(
@Immutable
sealed interface FocusRequestState {
data object None : FocusRequestState
+ data class Requested(val eventId: EventId, val debounce: Duration) : FocusRequestState
data class Loading(val eventId: EventId) : FocusRequestState
data class Success(
val eventId: EventId,
@@ -54,6 +59,7 @@ sealed interface FocusRequestState {
fun eventId(): EventId? {
return when (this) {
+ is Requested -> eventId
is Loading -> eventId
is Success -> eventId
else -> null
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index f6b58e2799..f1abc52725 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
import kotlinx.collections.immutable.ImmutableList
@@ -50,6 +51,7 @@ fun aTimelineState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
+ messageShield: MessageShield? = null,
eventSink: (TimelineEvents) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId
@@ -65,6 +67,7 @@ fun aTimelineState(
newEventState = NewEventState.None,
isLive = isLive,
focusRequestState = focusRequestState,
+ messageShield = messageShield,
eventSink = eventSink,
)
}
@@ -81,7 +84,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = false,
content = content,
groupPosition = TimelineItemGroupPosition.Middle,
- sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
+ sendState = LocalEventSendState.Failed.Unknown("Message failed to send"),
),
aTimelineItemEvent(
isMine = false,
@@ -104,7 +107,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = true,
content = content,
groupPosition = TimelineItemGroupPosition.Middle,
- sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
+ sendState = LocalEventSendState.Failed.Unknown("Message failed to send"),
),
aTimelineItemEvent(
isMine = true,
@@ -127,6 +130,7 @@ internal fun aTimelineItemEvent(
transactionId: TransactionId? = null,
isMine: Boolean = false,
isEditable: Boolean = false,
+ canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender",
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
@@ -137,6 +141,7 @@ internal fun aTimelineItemEvent(
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
+ messageShield: MessageShield? = null,
): TimelineItem.Event {
return TimelineItem.Event(
id = UUID.randomUUID().toString(),
@@ -150,6 +155,7 @@ internal fun aTimelineItemEvent(
sentTime = "12:34",
isMine = isMine,
isEditable = isEditable,
+ canBeRepliedTo = canBeRepliedTo,
senderProfile = aProfileTimelineDetailsReady(
displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous,
@@ -159,7 +165,8 @@ internal fun aTimelineItemEvent(
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
- origin = null
+ origin = null,
+ messageShield = messageShield,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index eff2019a11..62aa351cc4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -48,13 +48,17 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
+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 io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
@@ -65,12 +69,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState
+import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlin.math.abs
@@ -90,7 +96,9 @@ fun TimelineView(
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
- forceJumpToBottomVisibility: Boolean = false
+ lazyListState: LazyListState = rememberLazyListState(),
+ forceJumpToBottomVisibility: Boolean = false,
+ nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvents.ClearFocusRequestState)
@@ -109,7 +117,6 @@ fun TimelineView(
}
val context = LocalContext.current
- val lazyListState = rememberLazyListState()
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
@@ -120,11 +127,17 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
+ fun onShieldClick(shield: MessageShield) {
+ state.eventSink(TimelineEvents.ShowShieldDialog(shield))
+ }
+
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollConnection),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
@@ -148,6 +161,7 @@ fun TimelineView(
focusedEventId = state.focusedEventId,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
+ onShieldClick = ::onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = ::inReplyToClick,
@@ -180,6 +194,17 @@ fun TimelineView(
)
}
}
+
+ MessageShieldDialog(state)
+}
+
+@Composable
+private fun MessageShieldDialog(state: TimelineState) {
+ val messageShield = state.messageShield ?: return
+ AlertDialog(
+ content = messageShield.toText(),
+ onDismiss = { state.eventSink.invoke(TimelineEvents.HideShieldDialog) },
+ )
}
@Composable
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt
new file mode 100644
index 0000000000..68baf476a3
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import io.element.android.features.messages.impl.timeline.components.aCriticalShield
+import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.features.messages.impl.typing.aTypingNotificationState
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import kotlinx.collections.immutable.toImmutableList
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineViewMessageShieldPreview() = ElementPreview {
+ val timelineItems = aTimelineItemList(aTimelineItemTextContent())
+ // For consistency, ensure that there is a message in the timeline (the last one) with an error.
+ val messageShield = aCriticalShield()
+ val items = listOf(
+ (timelineItems.first() as TimelineItem.Event).copy(messageShield = messageShield)
+ ) + timelineItems.drop(1)
+ CompositionLocalProvider(
+ LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
+ ) {
+ TimelineView(
+ state = aTimelineState(
+ timelineItems = items.toImmutableList(),
+ messageShield = messageShield,
+ ),
+ typingNotificationState = aTypingNotificationState(),
+ onUserDataClick = {},
+ onLinkClick = {},
+ onMessageClick = {},
+ onMessageLongClick = {},
+ onSwipeToReply = {},
+ onReactionClick = { _, _ -> },
+ onReactionLongClick = { _, _ -> },
+ onMoreReactionsClick = {},
+ onReadReceiptClick = {},
+ onJoinCallClick = {},
+ forceJumpToBottomVisibility = true,
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
index 8e482c020f..959b02bc4a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
@@ -37,6 +37,7 @@ internal fun ATimelineItemEventRow(
isHighlighted = isHighlighted,
onClick = {},
onLongClick = {},
+ onShieldClick = {},
onUserDataClick = {},
onLinkClick = {},
inReplyToClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt
new file mode 100644
index 0000000000..9f9dc0ba05
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+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.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
+import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+internal fun MessageShieldView(
+ shield: MessageShield,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ ) {
+ Icon(
+ imageVector = shield.toIcon(),
+ contentDescription = null,
+ modifier = Modifier.size(15.dp),
+ tint = shield.toIconColor(),
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ text = shield.toText(),
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = shield.toTextColor()
+ )
+ }
+}
+
+@Composable
+internal fun MessageShield.toIconColor(): Color {
+ return when (isCritical) {
+ true -> ElementTheme.colors.iconCriticalPrimary
+ false -> ElementTheme.colors.iconSecondary
+ }
+}
+
+@Composable
+private fun MessageShield.toTextColor(): Color {
+ return when (isCritical) {
+ true -> ElementTheme.colors.textCriticalPrimary
+ false -> ElementTheme.colors.textSecondary
+ }
+}
+
+@Composable
+internal fun MessageShield.toText(): String {
+ return stringResource(
+ id = when (this) {
+ is MessageShield.AuthenticityNotGuaranteed -> CommonStrings.event_shield_reason_authenticity_not_guaranteed
+ is MessageShield.UnknownDevice -> CommonStrings.event_shield_reason_unknown_device
+ is MessageShield.UnsignedDevice -> CommonStrings.event_shield_reason_unsigned_device
+ is MessageShield.UnverifiedIdentity -> CommonStrings.event_shield_reason_unverified_identity
+ is MessageShield.SentInClear -> CommonStrings.event_shield_reason_sent_in_clear
+ is MessageShield.PreviouslyVerified -> CommonStrings.event_shield_reason_previously_verified
+ }
+ )
+}
+
+@Composable
+internal fun MessageShield.toIcon(): ImageVector {
+ return when (this) {
+ is MessageShield.AuthenticityNotGuaranteed -> CompoundIcons.Info()
+ is MessageShield.UnknownDevice,
+ is MessageShield.UnsignedDevice,
+ is MessageShield.UnverifiedIdentity,
+ is MessageShield.PreviouslyVerified -> CompoundIcons.HelpSolid()
+ is MessageShield.SentInClear -> CompoundIcons.LockOff()
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MessageShieldViewPreview() {
+ ElementPreview {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ MessageShieldView(
+ shield = MessageShield.UnknownDevice(true)
+ )
+ MessageShieldView(
+ shield = MessageShield.UnverifiedIdentity(true)
+ )
+ MessageShieldView(
+ shield = MessageShield.AuthenticityNotGuaranteed(false)
+ )
+ MessageShieldView(
+ shield = MessageShield.UnsignedDevice(false)
+ )
+ MessageShieldView(
+ shield = MessageShield.SentInClear(false)
+ )
+ MessageShieldView(
+ shield = MessageShield.PreviouslyVerified(false)
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
index 25181d7eb3..86781e5c87 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt
@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -33,26 +34,31 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.isEdited
+import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
+import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineEventTimestampView(
event: TimelineItem.Event,
+ onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier,
) {
val formattedTime = event.sentTime
- val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable
+ val hasError = event.localSendState is LocalEventSendState.Failed
+ val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
val isMessageEdited = event.content.isEdited()
- val tint = if (hasUnrecoverableError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
+ val tint = if (hasError || hasEncryptionCritical) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
Row(
modifier = Modifier
- .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
- .then(modifier),
+ .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
+ .then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
@@ -68,7 +74,7 @@ fun TimelineEventTimestampView(
style = ElementTheme.typography.fontBodyXsRegular,
color = tint,
)
- if (hasUnrecoverableError) {
+ if (hasError) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error(),
@@ -77,13 +83,28 @@ fun TimelineEventTimestampView(
modifier = Modifier.size(15.dp, 18.dp),
)
}
+ event.messageShield?.let { shield ->
+ Spacer(modifier = Modifier.width(2.dp))
+ Icon(
+ imageVector = shield.toIcon(),
+ contentDescription = shield.toText(),
+ modifier = Modifier
+ .size(15.dp)
+ .clickable { onShieldClick(shield) },
+ tint = shield.toIconColor(),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
}
}
@PreviewsDayNight
@Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
- TimelineEventTimestampView(event = event)
+ TimelineEventTimestampView(
+ event = event,
+ onShieldClick = {},
+ )
}
object TimelineEventTimestampViewDefaults {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt
index 84a93f1c09..b7237720bd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt
@@ -21,21 +21,26 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aTimelineItemEvent(),
- // Sending failed recoverable
- aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Recoverable("AN_ERROR")),
- // Sending failed unrecoverable
- aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR")),
+ aTimelineItemEvent().copy(localSendState = LocalEventSendState.Sending),
+ aTimelineItemEvent().copy(localSendState = LocalEventSendState.Failed.Unknown("AN_ERROR")),
// Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy(
- localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
+ localSendState = LocalEventSendState.Failed.Unknown("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
+ aTimelineItemEvent().copy(
+ messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false),
+ ),
+ aTimelineItemEvent().copy(
+ messageShield = MessageShield.UnknownDevice(isCritical = true),
+ ),
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index a2ac0ac7b1..8a6fccf2eb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -77,7 +77,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
-import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -91,6 +90,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
@@ -119,6 +119,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
+ onShieldClick: (MessageShield) -> Unit,
onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
@@ -148,7 +149,7 @@ fun TimelineItemEventRow(
} else {
Spacer(modifier = Modifier.height(2.dp))
}
- val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.content.canBeRepliedTo()
+ val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.floatValue
@@ -181,6 +182,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
+ onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
@@ -199,6 +201,7 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
+ onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
@@ -254,6 +257,7 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
+ onShieldClick: (MessageShield) -> Unit,
inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit,
@@ -321,6 +325,7 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
+ onShieldClick = onShieldClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onLinkClick = onLinkClick,
@@ -377,9 +382,11 @@ private fun MessageSenderInformation(
}
}
+@Suppress("MultipleEmitters") // False positive
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
+ onShieldClick: (MessageShield) -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onLinkClick: (String) -> Unit,
@@ -420,6 +427,7 @@ private fun MessageEventBubbleContent(
@Composable
fun WithTimestampLayout(
timestampPosition: TimestampPosition,
+ onShieldClick: (MessageShield) -> Unit,
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit,
@@ -430,6 +438,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
+ onShieldClick = onShieldClick,
modifier = Modifier
// Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp)
@@ -450,6 +459,7 @@ private fun MessageEventBubbleContent(
overlay = {
TimelineEventTimestampView(
event = event,
+ onShieldClick = onShieldClick,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
@@ -460,6 +470,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
+ onShieldClick = onShieldClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -507,6 +518,7 @@ private fun MessageEventBubbleContent(
val contentWithTimestamp = @Composable {
WithTimestampLayout(
timestampPosition = timestampPosition,
+ onShieldClick = onShieldClick,
canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier,
) { onContentLayoutChange ->
@@ -519,6 +531,7 @@ private fun MessageEventBubbleContent(
)
}
}
+
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val inReplyToModifier = Modifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt
new file mode 100644
index 0000000000..828060fd1f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineItemEventRowShieldPreview() = ElementPreview {
+ Column {
+ ATimelineItemEventRow(
+ event = aTimelineItemEvent(
+ senderDisplayName = "Sender with a super long name that should ellipsize",
+ isMine = true,
+ content = aTimelineItemTextContent(
+ body = "Message sent from unsigned device"
+ ),
+ groupPosition = TimelineItemGroupPosition.First,
+ messageShield = aCriticalShield()
+ ),
+ )
+ ATimelineItemEventRow(
+ event = aTimelineItemEvent(
+ senderDisplayName = "Sender with a super long name that should ellipsize",
+ content = aTimelineItemTextContent(
+ body = "Short Message with authenticity warning"
+ ),
+ groupPosition = TimelineItemGroupPosition.Middle,
+ messageShield = aWarningShield()
+ ),
+ )
+ ATimelineItemEventRow(
+ event = aTimelineItemEvent(
+ isMine = true,
+ content = aTimelineItemImageContent().copy(
+ aspectRatio = 2.5f
+ ),
+ groupPosition = TimelineItemGroupPosition.Last,
+ messageShield = aCriticalShield()
+ ),
+ )
+ ATimelineItemEventRow(
+ event = aTimelineItemEvent(
+ content = aTimelineItemImageContent().copy(
+ aspectRatio = 2.5f
+ ),
+ groupPosition = TimelineItemGroupPosition.Last,
+ messageShield = aWarningShield()
+ ),
+ )
+ }
+}
+
+private fun aWarningShield() = MessageShield.AuthenticityNotGuaranteed(isCritical = false)
+
+internal fun aCriticalShield() = MessageShield.UnverifiedIdentity(isCritical = true)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
index 4dd4d4e356..bb1cb2b2df 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
@@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@Composable
fun TimelineItemGroupedEventsRow(
@@ -46,6 +47,7 @@ fun TimelineItemGroupedEventsRow(
focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
+ onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@@ -72,6 +74,7 @@ fun TimelineItemGroupedEventsRow(
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick,
onLongClick = onLongClick,
+ onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@@ -95,6 +98,7 @@ private fun TimelineItemGroupedEventsRowContent(
isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
+ onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@@ -127,6 +131,7 @@ private fun TimelineItemGroupedEventsRowContent(
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
+ onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@@ -168,6 +173,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
+ onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
@@ -192,6 +198,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
+ onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt
index b4ec1e7461..77e6c5b9ff 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt
@@ -110,7 +110,7 @@ fun TimelineItemReactionsLayout(
}
val rows = rowsIn.toMutableList()
val secondLastRow = rows[rows.size - 2].toMutableList()
- val expandButtonPlaceable = secondLastRow.removeLast()
+ val expandButtonPlaceable = secondLastRow.removeAt(secondLastRow.lastIndex)
lastRow.add(0, expandButtonPlaceable)
rows[rows.size - 2] = secondLastRow
rows[rows.size - 1] = lastRow
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
index 2a3a0305c2..9cbcf20bef 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
@@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@Composable
internal fun TimelineItemRow(
@@ -49,6 +50,7 @@ internal fun TimelineItemRow(
onLinkClick: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
+ onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -110,6 +112,7 @@ internal fun TimelineItemRow(
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
+ onShieldClick = onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = inReplyToClick,
@@ -132,6 +135,7 @@ internal fun TimelineItemRow(
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
+ onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt
index 1595e59f54..20bca9fbd7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt
@@ -26,7 +26,7 @@ class ReadReceiptViewStateForTimelineItemEventRowProvider :
override val values: Sequence
get() = sequenceOf(
aReadReceiptViewState(
- sendState = LocalEventSendState.NotSentYet
+ sendState = LocalEventSendState.Sending,
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
index a40e94c529..6288919abe 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
@@ -29,7 +29,7 @@ class ReadReceiptViewStateProvider : PreviewParameterProvider
get() = sequenceOf(
aReadReceiptViewState(),
- aReadReceiptViewState(sendState = LocalEventSendState.NotSentYet),
+ aReadReceiptViewState(sendState = LocalEventSendState.Sending),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
index 0342f684dd..d8cdcbe05f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
@@ -70,19 +70,18 @@ fun TimelineItemReadReceiptView(
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
- .testTag(TestTags.messageReadReceipts)
- .clip(RoundedCornerShape(4.dp))
- .clickable {
- onReadReceiptsClick()
- }
- .padding(2.dp)
+ .testTag(TestTags.messageReadReceipts)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable {
+ onReadReceiptsClick()
+ }
+ .padding(2.dp)
)
}
}
} else {
when (state.sendState) {
- LocalEventSendState.NotSentYet,
- is LocalEventSendState.SendingFailed.Recoverable -> {
+ LocalEventSendState.Sending -> {
ReadReceiptsRow(modifier) {
Icon(
modifier = Modifier.padding(2.dp),
@@ -92,7 +91,7 @@ fun TimelineItemReadReceiptView(
)
}
}
- is LocalEventSendState.SendingFailed.Unrecoverable -> {
+ is LocalEventSendState.Failed -> {
// Error? The timestamp is already displayed in red
}
null,
@@ -119,9 +118,9 @@ private fun ReadReceiptsRow(
) {
Row(
modifier = modifier
- .fillMaxWidth()
- .height(AvatarSize.TimelineReadReceipt.dp + 8.dp)
- .padding(horizontal = 18.dp),
+ .fillMaxWidth()
+ .height(AvatarSize.TimelineReadReceipt.dp + 8.dp)
+ .padding(horizontal = 18.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -160,11 +159,11 @@ private fun ReadReceiptsAvatars(
.forEachIndexed { index, readReceiptData ->
Box(
modifier = Modifier
- .padding(end = (12.dp + avatarStrokeSize * 2) * index)
- .size(size = avatarSize + avatarStrokeSize * 2)
- .clip(CircleShape)
- .background(avatarStrokeColor)
- .zIndex(index.toFloat()),
+ .padding(end = (12.dp + avatarStrokeSize * 2) * index)
+ .size(size = avatarSize + avatarStrokeSize * 2)
+ .clip(CircleShape)
+ .background(avatarStrokeColor)
+ .zIndex(index.toFloat()),
contentAlignment = Alignment.Center,
) {
Avatar(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index c5d303b3f1..bbf00a83a9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -76,6 +76,7 @@ class TimelineItemEventFactory @Inject constructor(
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
+ canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
@@ -85,6 +86,7 @@ class TimelineItemEventFactory @Inject constructor(
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
+ messageShield = currentTimelineItem.event.messageShield,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
index f77db70506..2cb872e95f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
@@ -74,6 +75,7 @@ sealed interface TimelineItem {
val sentTime: String = "",
val isMine: Boolean = false,
val isEditable: Boolean,
+ val canBeRepliedTo: Boolean,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,
@@ -82,12 +84,13 @@ sealed interface TimelineItem {
val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
+ val messageShield: MessageShield?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
- val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
+ val failedToSend: Boolean = localSendState is LocalEventSendState.Failed
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index 43fb947433..5a52f476f1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -24,27 +24,27 @@ sealed interface TimelineItemEventContent {
}
/**
- * Only text based content and states can be copied.
+ * Only text based content can be copied.
*/
fun TimelineItemEventContent.canBeCopied(): Boolean =
- when (this) {
- is TimelineItemTextBasedContent,
- is TimelineItemStateContent,
- is TimelineItemRedactedContent -> true
- else -> false
- }
+ this is TimelineItemTextBasedContent
/**
- * Determine if the event content can be replied to.
- * Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
+ * Returns true if the event content can be forwarded.
*/
-fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
+fun TimelineItemEventContent.canBeForwarded(): Boolean =
when (this) {
- is TimelineItemRedactedContent,
- is TimelineItemLegacyCallInviteContent,
- is TimelineItemCallNotifyContent,
- is TimelineItemStateContent -> false
- else -> true
+ is TimelineItemTextBasedContent,
+ is TimelineItemImageContent,
+ is TimelineItemFileContent,
+ is TimelineItemAudioContent,
+ is TimelineItemVideoContent,
+ is TimelineItemLocationContent,
+ is TimelineItemVoiceContent -> true
+ // Stickers can't be forwarded (yet) so we don't show the option
+ // See https://github.com/element-hq/element-x-android/issues/2161
+ is TimelineItemStickerContent -> false
+ else -> false
}
/**
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
index c4a8a88153..9dadb7515d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
@@ -33,11 +33,12 @@ internal fun MessagesViewWithTypingPreview(
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
- onPreviewAttachments = {},
onUserDataClick = {},
onLinkClick = {},
+ onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = {},
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt
index c568635a02..4ab81677d3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt
@@ -53,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
+@Suppress("MultipleEmitters") // False positive
@Composable
fun TypingNotificationView(
state: TypingNotificationState,
diff --git a/features/messages/impl/src/main/res/values-nl/translations.xml b/features/messages/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..e7464c63bd
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,53 @@
+
+
+ "Activiteiten"
+ "Vlaggen"
+ "Eten & Drinken"
+ "Dieren & Natuur"
+ "Voorwerpen"
+ "Smileys & Mensen"
+ "Reizen & Locaties"
+ "Symbolen"
+ "Gebruiker blokkeren"
+ "Vink aan als je alle huidige en toekomstige berichten van deze gebruiker wilt verbergen"
+ "Dit bericht wordt gerapporteerd aan de beheerder van je homeserver. Ze zullen geen versleutelde berichten kunnen lezen."
+ "Reden voor het melden van deze inhoud"
+ "Camera"
+ "Foto maken"
+ "Video opnemen"
+ "Bijlage"
+ "Foto & Video Bibliotheek"
+ "Locatie"
+ "Peiling"
+ "Tekstopmaak"
+ "Berichtgeschiedenis is momenteel niet beschikbaar."
+ "Berichtgeschiedenis is niet beschikbaar in deze kamer. Verifieer dit apparaat om je berichtgeschiedenis te bekijken."
+ "Wil je ze terug uitnodigen?"
+ "Je bent alleen in deze chat"
+ "Stuur een melding naar de hele kamer"
+ "Iedereen"
+ "Opnieuw verzenden"
+ "Je bericht is niet verzonden"
+ "Emoji toevoegen"
+ "Dit is het begin van %1$s."
+ "Dit is het begin van dit gesprek."
+ "Toon minder"
+ "Bericht gekopieerd"
+ "Je hebt geen toestemming om berichten in deze kamer te plaatsen"
+ "Minder tonen"
+ "Meer tonen"
+ "Nieuw"
+
+ - "%1$d kamerverandering"
+ - "%1$d kamerveranderingen"
+
+
+ - "%1$s, %2$s en %3$d andere"
+ - "%1$s, %2$s en %3$d anderen"
+
+
+ - "%1$s is aan het typen"
+ - "%1$s zijn aan het typen"
+
+ "%1$s en %2$s"
+
diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml
index ebb2b54369..c1ffb49d2c 100644
--- a/features/messages/impl/src/main/res/values-pl/translations.xml
+++ b/features/messages/impl/src/main/res/values-pl/translations.xml
@@ -39,12 +39,18 @@
"Nowe"
- "%1$d zmiana pokoju"
- - "%1$d zmian pokoju"
- - "%1$d zmiany pokoju"
+ - "%1$d zmiany pokoju"
+ - "%1$d zmian pokoju"
+
+
+ - "%1$s, %2$s i %3$d inny"
+ - "%1$s, %2$s i %3$d innych"
+ - "%1$s, %2$s i %3$d innych"
- - "%1$s piszę"
+ - "%1$s pisze"
- "%1$s piszą"
- "%1$s piszą"
+ "%1$s i %2$s"
diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml
index 6d324fd695..1c668ae3f6 100644
--- a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml
@@ -23,6 +23,7 @@
"O histórico de mensagens não está disponível no momento."
"Gostaria de convidá-los de volta?"
"Você está sozinho neste chat"
+ "Notificar a sala inteira"
"Todos"
"Enviar novamente"
"Sua mensagem não foi enviada"
@@ -36,7 +37,16 @@
"Mostrar mais"
"Novo"
- - "%1$d mudança de sala"
- - "%1$d mudanças de salas"
+ - "%1$d alteração na sala"
+ - "%1$d alterações na sala"
+
+ - "%1$s, %2$s e %3$d outro"
+ - "%1$s, %2$s e %3$d outros"
+
+
+ - "%1$s está digitando"
+ - "%1$s estão digitando"
+
+ "%1$s e %2$s"
diff --git a/features/messages/impl/src/main/res/values-uz/translations.xml b/features/messages/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..78681df692
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,42 @@
+
+
+ "Faoliyatlar"
+ "Bayroqlar"
+ "Oziq-ovqat va ichimliklar"
+ "Hayvonlar va tabiat"
+ "Ob\'ektlar"
+ "Smayllar va odamlar"
+ "Sayohat va Joylar"
+ "Belgilar"
+ "Foydalanuvchini bloklash"
+ "Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"
+ "Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."
+ "Ushbu kontent haqida xabar berish sababi"
+ "Kamera"
+ "Rasmga olmoq"
+ "Video yozib olish"
+ "Biriktirma"
+ "Fotosurat va video kutubxonasi"
+ "Joylashuv"
+ "So\'ro\'vnoma"
+ "Matnni formatlash"
+ "Xabarlar tarixi hozirda mavjud emas."
+ "Ularni yana taklif qilmoqchimisiz?"
+ "Siz bu chatda yolg\'izsiz"
+ "Har kim"
+ "Yana yuboring"
+ "Xabaringiz yuborilmadi"
+ "Emoji qo\'shmoq"
+ "Bu %1$sni boshlanishi"
+ "Bu suhbatning boshlanishi."
+ "Kamroq ko\'rsatish"
+ "Xabar nusxalandi"
+ "Sizda bu xonaga post yozishga ruxsat yo‘q"
+ "Kamroq ko\'rsatish"
+ "Ko\'proq ko\'rsatish"
+ "Yangi"
+
+ - "%1$dxonani almashtirish"
+ - "%1$dxona o\'zgarishi"
+
+
diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml
index 8e2e049453..6276eb5959 100644
--- a/features/messages/impl/src/main/res/values-zh/translations.xml
+++ b/features/messages/impl/src/main/res/values-zh/translations.xml
@@ -10,7 +10,7 @@
"符号"
"封禁用户"
"请确认是否要隐藏该用户当前和未来的所有信息"
- "此消息将举报给您的主服务器管理员。他们无法读取任何加密消息。"
+ "此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"
"举报此内容的原因"
"相机"
"拍摄照片"
@@ -26,7 +26,7 @@
"聊天中只有你一个人"
"通知整个房间"
"所有人"
- "重新发送"
+ "再次发送"
"消息发送失败"
"添加表情符号"
"这是 %1$s 聊天室的开始。"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 091305893e..c9700a60bd 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -29,8 +29,11 @@ import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
+import io.element.android.features.messages.impl.messagecomposer.FakeRoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
-import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
+import io.element.android.features.messages.impl.messagecomposer.TestRichTextEditorStateFactory
+import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
+import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.TimelinePresenter
@@ -43,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
@@ -77,6 +81,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
+import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@@ -137,8 +142,8 @@ class MessagesPresenterTest {
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
- assertThat(initialState.userHasPermissionToSendMessage).isTrue()
- assertThat(initialState.userHasPermissionToRedactOwn).isTrue()
+ assertThat(initialState.userEventPermissions.canSendMessage).isTrue()
+ assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
@@ -155,6 +160,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
@@ -175,6 +181,7 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
@@ -203,6 +210,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
@@ -240,6 +248,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
@@ -298,6 +307,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper,
@@ -487,6 +497,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
@@ -561,6 +572,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -596,6 +608,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -620,6 +633,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -644,6 +658,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
@@ -679,6 +694,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState(
MatrixRoomMembersState.Error(
@@ -715,6 +731,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room)
@@ -741,6 +758,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
@@ -781,13 +799,14 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitFirstItem()
- assertThat(state.userHasPermissionToSendMessage).isTrue()
+ assertThat(state.userEventPermissions.canSendMessage).isTrue()
}
}
@@ -805,15 +824,16 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Default value
- assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
+ assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue()
skipItems(1)
- assertThat(awaitItem().userHasPermissionToSendMessage).isFalse()
+ assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -826,14 +846,15 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOwn }.last()
- assertThat(initialState.userHasPermissionToRedactOwn).isTrue()
- assertThat(initialState.userHasPermissionToRedactOther).isFalse()
+ val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last()
+ assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
+ assertThat(initialState.userEventPermissions.canRedactOther).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -846,14 +867,15 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOther }.last()
- assertThat(initialState.userHasPermissionToRedactOwn).isFalse()
- assertThat(initialState.userHasPermissionToRedactOther).isTrue()
+ val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last()
+ assertThat(initialState.userEventPermissions.canRedactOwn).isFalse()
+ assertThat(initialState.userEventPermissions.canRedactOther).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@@ -878,6 +900,74 @@ class MessagesPresenterTest {
}
}
+ @Test
+ fun `present - handle action pin`() = runTest {
+ val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
+ val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) }
+ val timeline = FakeTimeline()
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
+ )
+ val presenter = createMessagesPresenter(matrixRoom = room)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemTextContent()
+ )
+ val initialState = awaitFirstItem()
+
+ timeline.pinEventLambda = successPinEventLambda
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent))
+ assert(successPinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
+
+ timeline.pinEventLambda = failurePinEventLambda
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent))
+ assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
+ assertThat(awaitItem().snackbarMessage).isNotNull()
+ }
+ }
+
+ @Test
+ fun `present - handle action unpin`() = runTest {
+ val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
+ val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) }
+ val timeline = FakeTimeline()
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
+ )
+ val presenter = createMessagesPresenter(matrixRoom = room)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemTextContent()
+ )
+ val initialState = awaitFirstItem()
+
+ timeline.unpinEventLambda = successUnpinEventLambda
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent))
+ assert(successUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
+
+ timeline.unpinEventLambda = failureUnpinEventLambda
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent))
+ assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
+ assertThat(awaitItem().snackbarMessage).isNotNull()
+ }
+ }
+
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
// Skip 2 item if Mentions feature is enabled, else 1
skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1)
@@ -892,6 +982,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
@@ -919,6 +1010,7 @@ class MessagesPresenterTest {
analyticsService = analyticsService,
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
+ roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = permissionsPresenterFactory,
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
@@ -927,6 +1019,7 @@ class MessagesPresenterTest {
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = FakeTextPillificationHelper(),
roomMemberProfilesCache = RoomMemberProfilesCache(),
+ suggestionsProcessor = SuggestionsProcessor(),
).apply {
showTextFormatting = true
isTesting = true
@@ -958,14 +1051,21 @@ class MessagesPresenterTest {
return timelinePresenter
}
}
- val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore)
+ val featureFlagService = FakeFeatureFlagService()
+ val actionListPresenter = ActionListPresenter(
+ appPreferencesStore = appPreferencesStore,
+ featureFlagsService = featureFlagService,
+ room = matrixRoom,
+ )
val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore,
)
+
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
+
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
@@ -976,11 +1076,12 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
+ pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,
clipboardHelper = clipboardHelper,
- featureFlagsService = FakeFeatureFlagService(),
+ featureFlagsService = featureFlagService,
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index 9a22ced505..b304ad9178 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -26,12 +26,14 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
+import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
@@ -42,8 +44,11 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
+import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem
+import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
+import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
+import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
-import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineState
@@ -53,8 +58,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
@@ -73,6 +78,7 @@ import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
+import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class)
class MessagesViewTest {
@@ -169,16 +175,20 @@ class MessagesViewTest {
userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false,
+ userCanPinEvent: Boolean = false,
) {
val eventsRecorder = EventsRecorder()
val state = aMessagesState(
actionListState = anActionListState(
eventSink = eventsRecorder
),
- userHasPermissionToSendMessage = userHasPermissionToSendMessage,
- userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
- userHasPermissionToRedactOther = userHasPermissionToRedactOther,
- userHasPermissionToSendReaction = userHasPermissionToSendReaction,
+ userEventPermissions = UserEventPermissions(
+ canSendMessage = userHasPermissionToSendMessage,
+ canRedactOwn = userHasPermissionToRedactOwn,
+ canRedactOther = userHasPermissionToRedactOther,
+ canSendReaction = userHasPermissionToSendReaction,
+ canPinUnpin = userCanPinEvent,
+ ),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
@@ -189,10 +199,7 @@ class MessagesViewTest {
eventsRecorder.assertSingle(
ActionListEvents.ComputeForMessage(
event = timelineItem,
- canRedactOwn = state.userHasPermissionToRedactOwn,
- canRedactOther = state.userHasPermissionToRedactOther,
- canSendMessage = state.userHasPermissionToSendMessage,
- canSendReaction = state.userHasPermissionToSendReaction,
+ userEventPermissions = state.userEventPermissions,
)
)
}
@@ -237,9 +244,11 @@ class MessagesViewTest {
private fun swipeTest(userHasPermissionToSendMessage: Boolean) {
val eventsRecorder = EventsRecorder()
+ val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true)
+ val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false)
val state = aMessagesState(
timelineState = aTimelineState(
- timelineItems = aTimelineItemList(aTimelineItemTextContent()),
+ timelineItems = persistentListOf(canBeRepliedEvent, cannotBeRepliedEvent),
timelineRoomInfo = aTimelineRoomInfo(
userHasPermissionToSendMessage = userHasPermissionToSendMessage
),
@@ -249,10 +258,12 @@ class MessagesViewTest {
rule.setMessagesView(
state = state,
)
- rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { swipeRight(endX = 200f) }
+ rule.onAllNodesWithTag(TestTags.messageBubble.value).apply {
+ onFirst().performTouchInput { swipeRight(endX = 200f) }
+ onLast().performTouchInput { swipeRight(endX = 200f) }
+ }
if (userHasPermissionToSendMessage) {
- val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
- eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, timelineItem))
+ eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, canBeRepliedEvent))
} else {
eventsRecorder.assertEmpty()
}
@@ -454,6 +465,25 @@ class MessagesViewTest {
customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!))
}
+
+ @Test
+ fun `clicking on pinned messages banner emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val state = aMessagesState(
+ timelineState = aTimelineState(eventSink = eventsRecorder),
+ pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
+ knownPinnedMessagesCount = 2,
+ currentPinnedMessageIndex = 0,
+ currentPinnedMessage = PinnedMessagesBannerItem(
+ eventId = AN_EVENT_ID,
+ formatted = AnnotatedString("This is a pinned message")
+ ),
+ ),
+ )
+ rule.setMessagesView(state = state)
+ rule.onNodeWithText("This is a pinned message").performClick()
+ eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
+ }
}
private fun AndroidComposeTestRule.setMessagesView(
@@ -467,6 +497,7 @@ private fun AndroidComposeTestRule.setMessa
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
+ onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
@@ -484,6 +515,7 @@ private fun AndroidComposeTestRule.setMessa
onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,
+ onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index 76c43a9c8b..e8ea7cbbc2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.aUserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@@ -31,7 +32,13 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
@@ -46,7 +53,7 @@ class ActionListPresenterTest {
@Test
fun `present - initial state`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -57,7 +64,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for message from me redacted`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -66,10 +73,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -91,7 +101,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for message from others redacted`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -104,10 +114,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -129,7 +142,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -142,10 +155,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -158,6 +174,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -172,7 +189,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message cannot sent message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -185,10 +202,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = false,
- canSendReaction = true
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = false,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -200,6 +220,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -214,7 +235,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message and can redact`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -227,10 +248,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = true,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = true,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
val successState = awaitItem()
@@ -241,6 +265,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -256,7 +281,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for others message and cannot send reaction`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -269,10 +294,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = true,
- canSendMessage = true,
- canSendReaction = false
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = true,
+ canSendMessage = true,
+ canSendReaction = false,
+ canPinUnpin = true,
+ )
)
)
val successState = awaitItem()
@@ -283,6 +311,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -298,7 +327,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for my message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -310,10 +339,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -327,6 +359,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -341,7 +374,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for my message cannot redact`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -353,10 +386,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -370,6 +406,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
+ TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
@@ -383,7 +420,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a media item`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -396,10 +433,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ ),
)
)
// val loadingState = awaitItem()
@@ -412,6 +452,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@@ -425,7 +466,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a state item in debug build`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -437,10 +478,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = stateEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
@@ -451,8 +495,6 @@ class ActionListPresenterTest {
event = stateEvent,
displayEmojiReactions = false,
actions = persistentListOf(
- TimelineItemAction.Copy,
- TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
)
)
@@ -464,7 +506,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for a state item in non-debuggable build`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -476,33 +518,24 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = stateEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
- val successState = awaitItem()
- assertThat(successState.target).isEqualTo(
- ActionListState.Target.Success(
- event = stateEvent,
- displayEmojiReactions = false,
- actions = persistentListOf(
- TimelineItemAction.Copy,
- TimelineItemAction.CopyLink,
- )
- )
- )
- initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message in non-debuggable build`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -514,10 +547,59 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
+ )
+ )
+ // val loadingState = awaitItem()
+ // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ actions = persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Edit,
+ TimelineItemAction.Pin,
+ TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute message when user can't pin`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = false,
+ )
)
)
// val loadingState = awaitItem()
@@ -533,6 +615,61 @@ class ActionListPresenterTest {
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute message when event is already pinned`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
+ }
+ val presenter = createActionListPresenter(
+ isDeveloperModeEnabled = true,
+ isPinFeatureEnabled = true,
+ room = room
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
+ )
+ )
+ // val loadingState = awaitItem()
+ // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ actions = persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Edit,
+ TimelineItemAction.Unpin,
+ TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
@@ -544,7 +681,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute message with no actions`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -561,10 +698,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
@@ -572,10 +712,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = redactedEvent,
- canRedactOwn = false,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ )
)
)
awaitItem().run {
@@ -586,7 +728,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute not sent message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -595,16 +737,20 @@ class ActionListPresenterTest {
// No event id, so it's not sent yet
eventId = null,
isMine = true,
+ canBeRepliedTo = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
val successState = awaitItem()
@@ -624,7 +770,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for editable poll message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -637,10 +783,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
val successState = awaitItem()
@@ -652,6 +801,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Edit,
TimelineItemAction.EndPoll,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
@@ -662,7 +812,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for non-editable poll message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -675,10 +825,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true
+ )
)
)
val successState = awaitItem()
@@ -689,6 +842,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.EndPoll,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
@@ -699,7 +853,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for ended poll message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -712,10 +866,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
)
)
val successState = awaitItem()
@@ -725,6 +882,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Reply,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
@@ -735,22 +893,26 @@ class ActionListPresenterTest {
@Test
fun `present - compute for voice message`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
+ isEditable = false,
content = aTimelineItemVoiceContent(),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true
+ )
)
)
val successState = awaitItem()
@@ -761,6 +923,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
@@ -771,7 +934,7 @@ class ActionListPresenterTest {
@Test
fun `present - compute for call notify`() = runTest {
- val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -783,10 +946,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
- canRedactOwn = true,
- canRedactOther = false,
- canSendMessage = true,
- canSendReaction = true,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ )
)
)
val successState = awaitItem()
@@ -803,7 +968,20 @@ class ActionListPresenterTest {
}
}
-private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {
+private fun createActionListPresenter(
+ isDeveloperModeEnabled: Boolean,
+ isPinFeatureEnabled: Boolean,
+ room: MatrixRoom = FakeMatrixRoom(),
+): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
- return ActionListPresenter(appPreferencesStore = preferencesStore)
+ val featureFlagsService = FakeFeatureFlagService(
+ initialState = mapOf(
+ FeatureFlags.PinnedEvents.key to isPinFeatureEnabled,
+ )
+ )
+ return ActionListPresenter(
+ appPreferencesStore = preferencesStore,
+ featureFlagsService = featureFlagsService,
+ room = room
+ )
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
index 7f2c3cfbeb..c712421f23 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -42,11 +43,13 @@ internal fun aMessageEvent(
transactionId: TransactionId? = null,
isMine: Boolean = true,
isEditable: Boolean = true,
+ canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
+ messageShield: MessageShield? = null,
) = TimelineItem.Event(
id = eventId?.value.orEmpty(),
eventId = eventId,
@@ -58,11 +61,13 @@ internal fun aMessageEvent(
sentTime = "",
isMine = isMine,
isEditable = isEditable,
+ canBeRepliedTo = canBeRepliedTo,
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()),
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
isThreaded = isThreaded,
- origin = null
+ origin = null,
+ messageShield = messageShield,
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt
new file mode 100644
index 0000000000..4fa976fbb4
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.messagecomposer
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultRoomAliasSuggestionsDataSourceTest {
+ @Test
+ fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest {
+ val roomListService = FakeRoomListService()
+ val sut = DefaultRoomAliasSuggestionsDataSource(
+ roomListService
+ )
+ val aRoomSummaryWithAnAlias = aRoomSummary(
+ canonicalAlias = A_ROOM_ALIAS
+ )
+ sut.getAllRoomAliasSuggestions().test {
+ assertThat(awaitItem()).isEmpty()
+ roomListService.postAllRooms(
+ listOf(
+ aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
+ aRoomSummaryWithAnAlias,
+ )
+ )
+ assertThat(awaitItem()).isEqualTo(
+ listOf(
+ RoomAliasSuggestion(
+ roomAlias = A_ROOM_ALIAS,
+ roomSummary = aRoomSummaryWithAnAlias
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt
new file mode 100644
index 0000000000..b7ee6f27ca
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.messagecomposer
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeRoomAliasSuggestionsDataSource(
+ initialData: List = emptyList()
+) : RoomAliasSuggestionsDataSource {
+ private val roomAliasSuggestions = MutableStateFlow(initialData)
+
+ override fun getAllRoomAliasSuggestions(): Flow> {
+ return roomAliasSuggestions
+ }
+
+ fun emitRoomAliasSuggestions(newData: List) {
+ roomAliasSuggestions.value = newData
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
similarity index 96%
rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index dcb09fb7c8..632f529632 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -16,7 +16,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
-package io.element.android.features.messages.impl.textcomposer
+package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import androidx.compose.runtime.remember
@@ -29,11 +29,7 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
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.AttachmentsState
-import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+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.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
@@ -50,9 +46,9 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
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.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
-import io.element.android.libraries.matrix.api.room.Mention
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
@@ -90,7 +86,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
-import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
+import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@@ -368,7 +364,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit sent message`() = runTest {
- val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@@ -420,21 +416,20 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit sent message event not found`() = runTest {
- val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
Result.failure(TimelineException.EventNotFound)
}
val timeline = FakeTimeline().apply {
this.editMessageLambda = timelineEditMessageLambda
}
- val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List ->
+ val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List ->
Result.success(Unit)
}
val fakeMatrixRoom = FakeMatrixRoom(
liveTimeline = timeline,
- typingNoticeResult = { Result.success(Unit) }
- ).apply {
- this.editMessageLambda = roomEditMessageLambda
- }
+ typingNoticeResult = { Result.success(Unit) },
+ editMessageLambda = roomEditMessageLambda,
+ )
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -481,7 +476,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit not sent message`() = runTest {
- val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@@ -533,7 +528,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@@ -975,34 +970,34 @@ class MessageComposerPresenterTest {
// A null suggestion (no suggestion was received) returns nothing
initialState.eventSink(MessageComposerEvents.SuggestionReceived(null))
- assertThat(awaitItem().memberSuggestions).isEmpty()
+ assertThat(awaitItem().suggestions).isEmpty()
// An empty suggestion returns the room and joined members that are not the current user
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
- assertThat(awaitItem().memberSuggestions)
- .containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
+ assertThat(awaitItem().suggestions)
+ .containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
// A suggestion containing a part of "room" will also return the room mention
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
- assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom)
+ assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom)
// A non-empty suggestion will return those joined members whose user id matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
- assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob))
+ assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(bob))
// A non-empty suggestion will return those joined members whose display name matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
- assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david))
+ assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(david))
// If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
- assertThat(awaitItem().memberSuggestions).isEmpty()
+ assertThat(awaitItem().suggestions).isEmpty()
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
canUserTriggerRoomNotificationResult = false
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
- assertThat(awaitItem().memberSuggestions)
- .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
+ assertThat(awaitItem().suggestions)
+ .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
}
}
@@ -1040,13 +1035,12 @@ class MessageComposerPresenterTest {
// An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
- assertThat(awaitItem().memberSuggestions)
- .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
+ assertThat(awaitItem().suggestions)
+ .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
}
}
- @Test
- fun `present - insertMention for user in rich text editor`() = runTest {
+ fun `present - InsertSuggestion`() = runTest {
val presenter = createPresenter(
coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder(
@@ -1060,7 +1054,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setHtml("Hey @bo")
- initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
+ initialState.eventSink(MessageComposerEvents.InsertSuggestion(ResolvedSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
assertThat(initialState.textEditorState.messageHtml())
.isEqualTo("Hey ${A_USER_ID_2.value}")
@@ -1070,17 +1064,17 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
- val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.replyMessageLambda = replyMessageLambda
this.editMessageLambda = editMessageLambda
}
- val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List ->
+ val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List ->
Result.success(Unit)
}
val room = FakeMatrixRoom(
@@ -1108,7 +1102,7 @@ class MessageComposerPresenterTest {
advanceUntilIdle()
sendMessageResult.assertions().isCalledOnce()
- .with(value(A_MESSAGE), any(), value(listOf(Mention.User(A_USER_ID))))
+ .with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
// Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
@@ -1125,7 +1119,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda)
.isCalledOnce()
- .with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))), value(false))
+ .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
// Check intentional mentions on edit message
skipItems(1)
@@ -1143,7 +1137,7 @@ class MessageComposerPresenterTest {
assert(editMessageLambda)
.isCalledOnce()
- .with(any(), any(), any(), any(), value(listOf(Mention.User(A_USER_ID_3))))
+ .with(any(), any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_3))))
skipItems(1)
}
@@ -1508,6 +1502,7 @@ class MessageComposerPresenterTest {
analyticsService,
DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(),
+ roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
@@ -1516,6 +1511,7 @@ class MessageComposerPresenterTest {
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
roomMemberProfilesCache = roomMemberProfilesCache,
+ suggestionsProcessor = SuggestionsProcessor(),
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt
similarity index 86%
rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt
rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt
index 921a7331fd..df5c1c6183 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt
@@ -14,10 +14,9 @@
* limitations under the License.
*/
-package io.element.android.features.messages.impl.textcomposer
+package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Composable
-import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt
new file mode 100644
index 0000000000..c3f7806cc4
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.messagecomposer.suggestions
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
+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.textcomposer.mentions.ResolvedSuggestion
+import io.element.android.libraries.textcomposer.model.Suggestion
+import io.element.android.libraries.textcomposer.model.SuggestionType
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.runTest
+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()
+
+ @Test
+ fun `processing null suggestion will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = null,
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Command will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = aCommandSuggestion,
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Custom will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = aCustomSuggestion,
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with not loaded members will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion(""),
+ roomMembersState = MatrixRoomMembersState.Unknown,
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with no members will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion(""),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Room suggestion with no aliases will return empty suggestion`() = runTest {
+ val result = suggestionsProcessor.process(
+ suggestion = aRoomSuggestion(""),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Room suggestion with aliases ignoring cases will return a suggestion`() = runTest {
+ val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
+ val result = suggestionsProcessor.process(
+ suggestion = aRoomSuggestion("ALI"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
+ roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
+ )
+ )
+ }
+
+ @Test
+ fun `processing Room suggestion with aliases will return a suggestion`() = runTest {
+ val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
+ val result = suggestionsProcessor.process(
+ suggestion = aRoomSuggestion("ali"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
+ roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
+ )
+ )
+ }
+
+ @Test
+ fun `processing Room suggestion with aliases not found will return no suggestions`() = runTest {
+ val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
+ val result = suggestionsProcessor.process(
+ suggestion = aRoomSuggestion("tot"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
+ roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
+ currentUserId = A_USER_ID,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with return matching matrix Id`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null)
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("ali"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.Member(aRoomMember)
+ )
+ )
+ }
+
+ @Test
+ fun `processing Mention suggestion with not return the current user`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null)
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("ali"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = UserId("@alice:server.org"),
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with return empty list if there is no matches`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "alice")
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("bo"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with not return not joined member`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), membership = RoomMembershipState.INVITE)
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("ali"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `processing Mention suggestion with return matching display name`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "bob")
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("bo"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.Member(aRoomMember)
+ )
+ )
+ }
+
+ @Test
+ fun `processing Mention suggestion with return matching display name and room if allowed`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("ro"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { true },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.AtRoom,
+ ResolvedSuggestion.Member(aRoomMember),
+ )
+ )
+ }
+
+ @Test
+ fun `processing Mention suggestion with return matching display name but not room if not allowed`() = runTest {
+ val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")
+ val result = suggestionsProcessor.process(
+ suggestion = aMentionSuggestion("ro"),
+ roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
+ roomAliasSuggestions = emptyList(),
+ currentUserId = A_USER_ID_2,
+ canSendRoomMention = { false },
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ ResolvedSuggestion.Member(aRoomMember),
+ )
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt
new file mode 100644
index 0000000000..1ae4729a40
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.aMessageContent
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class PinnedMessagesBannerPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = true)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - feature disabled`() = runTest {
+ val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = false)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
+ }
+ }
+
+ @Test
+ fun `present - loading state`() = runTest {
+ val room = FakeMatrixRoom(
+ pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }
+ ).apply {
+ givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
+ }
+ val presenter = createPinnedMessagesBannerPresenter(room = room)
+ presenter.test {
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
+ assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
+ assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
+ }
+ }
+
+ @Test
+ fun `present - loaded state`() = runTest {
+ val messageContent = aMessageContent("A message")
+ val pinnedEventsTimeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = "FAKE_UNIQUE_ID",
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID,
+ content = messageContent,
+ ),
+ )
+ )
+ )
+ )
+ val room = FakeMatrixRoom(
+ pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
+ ).apply {
+ givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
+ }
+ val presenter = createPinnedMessagesBannerPresenter(room = room)
+ presenter.test {
+ skipItems(2)
+ val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded
+ assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
+ assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
+ assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString())
+ }
+ }
+
+ @Test
+ fun `present - loaded state - multiple pinned messages`() = runTest {
+ val messageContent1 = aMessageContent("A message")
+ val messageContent2 = aMessageContent("Another message")
+ val pinnedEventsTimeline = FakeTimeline(
+ timelineItems = flowOf(
+ listOf(
+ MatrixTimelineItem.Event(
+ uniqueId = "FAKE_UNIQUE_ID",
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID,
+ content = messageContent1,
+ ),
+ ),
+ MatrixTimelineItem.Event(
+ uniqueId = "FAKE_UNIQUE_ID_2",
+ event = anEventTimelineItem(
+ eventId = AN_EVENT_ID_2,
+ content = messageContent2,
+ ),
+ )
+ )
+ )
+ )
+ val room = FakeMatrixRoom(
+ pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
+ ).apply {
+ givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
+ }
+ val presenter = createPinnedMessagesBannerPresenter(room = room)
+ presenter.test {
+ skipItems(2)
+ awaitItem().also { loadedState ->
+ loadedState as PinnedMessagesBannerState.Loaded
+ assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
+ assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
+ assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
+ loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
+ }
+
+ awaitItem().also { loadedState ->
+ loadedState as PinnedMessagesBannerState.Loaded
+ assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
+ assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
+ assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent1.toString())
+ loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
+ }
+
+ awaitItem().also { loadedState ->
+ loadedState as PinnedMessagesBannerState.Loaded
+ assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
+ assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
+ assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
+ }
+ }
+ }
+
+ @Test
+ fun `present - timeline failed`() = runTest {
+ val room = FakeMatrixRoom(
+ pinnedEventsTimelineResult = { Result.failure(Exception()) }
+ ).apply {
+ givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
+ }
+ val presenter = createPinnedMessagesBannerPresenter(room = room)
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { loadingState ->
+ assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
+ assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
+ assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
+ }
+ awaitItem().also { failedState ->
+ assertThat(failedState).isEqualTo(PinnedMessagesBannerState.Hidden)
+ }
+ }
+ }
+
+ private fun TestScope.createPinnedMessagesBannerPresenter(
+ room: MatrixRoom = FakeMatrixRoom(),
+ itemFactory: PinnedMessagesBannerItemFactory = PinnedMessagesBannerItemFactory(
+ coroutineDispatchers = testCoroutineDispatchers(),
+ formatter = FakePinnedMessagesBannerFormatter(
+ formatLambda = { event -> "${event.content}" }
+ )
+ ),
+ networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
+ isFeatureEnabled: Boolean = true,
+ ): PinnedMessagesBannerPresenter {
+ return PinnedMessagesBannerPresenter(
+ room = room,
+ itemFactory = itemFactory,
+ isFeatureEnabled = { isFeatureEnabled },
+ networkMonitor = networkMonitor,
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt
new file mode 100644
index 0000000000..ed23bdfb84
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.pinned.banner
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PinnedMessagesBannerViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on the banner invoke expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ val state = aLoadedPinnedMessagesBannerState(
+ eventSink = eventsRecorder
+ )
+ val pinnedEventId = state.currentPinnedMessage.eventId
+ ensureCalledOnceWithParam(pinnedEventId) { callback ->
+ rule.setPinnedMessagesBannerView(
+ state = state,
+ onClick = callback
+ )
+ rule.onRoot().performClick()
+ eventsRecorder.assertSingle(PinnedMessagesBannerEvents.MoveToNextPinned)
+ }
+ }
+
+ @Test
+ fun `clicking on view all emit the expected event`() {
+ val eventsRecorder = EventsRecorder(expectEvents = true)
+ val state = aLoadedPinnedMessagesBannerState(
+ eventSink = eventsRecorder
+ )
+ ensureCalledOnce { callback ->
+ rule.setPinnedMessagesBannerView(
+ state = state,
+ onViewAllClick = callback
+ )
+ rule.clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title)
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setPinnedMessagesBannerView(
+ state: PinnedMessagesBannerState,
+ onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(),
+ onViewAllClick: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ PinnedMessagesBannerView(
+ state = state,
+ onClick = onClick,
+ onViewAllClick = onViewAllClick
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
index 887ce88d95..a1b0e73b65 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
@@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.timeline
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.matrix.api.room.Mention
+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.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -162,10 +162,10 @@ class TimelineControllerTest {
@Test
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
- val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List ->
+ val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List ->
Result.success(Unit)
}
- val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List ->
+ val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List ->
Result.success(Unit)
}
val liveTimeline = FakeTimeline(name = "live").apply {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index 5a171227e7..a155507817 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -24,6 +24,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
+import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -75,6 +76,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.util.Date
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
@@ -496,6 +498,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
+ }
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
@@ -541,6 +547,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
+ }
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0))
@@ -564,6 +574,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
+ awaitItem().also { state ->
+ assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
+ assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
+ }
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
@@ -578,6 +592,26 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}
}
+ @Test
+ fun `present - show shield hide shield`() = runTest {
+ val presenter = createTimelinePresenter()
+ val shield = aCriticalShield()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.messageShield).isNull()
+ initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
+ awaitItem().also { state ->
+ assertThat(state.messageShield).isEqualTo(shield)
+ state.eventSink(TimelineEvents.HideShieldDialog)
+ }
+ awaitItem().also { state ->
+ assertThat(state.messageShield).isNull()
+ }
+ }
+ }
+
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeTimeline(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
index e66bd4c7a1..bb8ea58315 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
@@ -22,17 +22,21 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
@@ -97,6 +101,47 @@ class TimelineViewTest {
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(TimelineEvents.JumpToLive)
}
+
+ @Test
+ fun `show shield dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setTimelineView(
+ state = aTimelineState(
+ timelineItems = persistentListOf(
+ aTimelineItemEvent(
+ // Do not use a Text because EditorStyledText cannot be used in UI test.
+ content = aTimelineItemImageContent(),
+ messageShield = MessageShield.UnverifiedIdentity(true),
+ ),
+ ),
+ eventSink = eventsRecorder,
+ ),
+ )
+ val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity)
+ rule.onNodeWithContentDescription(contentDescription).performClick()
+ eventsRecorder.assertList(
+ listOf(
+ TimelineEvents.OnScrollFinished(0),
+ TimelineEvents.OnScrollFinished(0),
+ TimelineEvents.OnScrollFinished(0),
+ TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
+ )
+ )
+ }
+
+ @Test
+ fun `hide shield dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setTimelineView(
+ state = aTimelineState(
+ isLive = false,
+ eventSink = eventsRecorder,
+ messageShield = aCriticalShield(),
+ ),
+ )
+ rule.clickOn(CommonStrings.action_ok)
+ eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
+ }
}
private fun AndroidComposeTestRule.setTimelineView(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
index ce53e05090..fc98b34feb 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt
@@ -46,10 +46,12 @@ class TimelineItemGrouperTest {
readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
isEditable = false,
+ canBeRepliedTo = false,
inReplyTo = null,
isThreaded = false,
debugInfo = aTimelineItemDebugInfo(),
- origin = null
+ origin = null,
+ messageShield = null,
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt
index 4cabc6450a..93882d5f3e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt
@@ -85,6 +85,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf(
eventId = eventId,
transactionId = null,
isEditable = false,
+ canBeRepliedTo = false,
isLocal = false,
isOwn = false,
isRemote = false,
@@ -100,7 +101,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf(
originalJson = null,
latestEditedJson = null
),
- origin = null
+ origin = null,
+ messageShield = null,
),
)
)
diff --git a/features/onboarding/impl/src/main/res/values-nl/translations.xml b/features/onboarding/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..86bbf73ade
--- /dev/null
+++ b/features/onboarding/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Handmatig inloggen"
+ "Inloggen met QR-code"
+ "Account aanmaken"
+ "Welkom bij de snelste %1$s ooit. Supercharged, voor snelheid en eenvoud."
+ "Welkom bij %1$s. Supercharged, voor snelheid en eenvoud."
+ "Wees in je element"
+
diff --git a/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml
index 04a26fe212..965c66c78c 100644
--- a/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml
@@ -5,4 +5,5 @@
"Criar conta"
"Bem-vindo ao mais rápido %1$s de todos os tempos. Turbinado para velocidade e simplicidade."
"Bem-vindo ao %1$s. Turbinado, para velocidade e simplicidade"
+ "Esteja no seu elemento"
diff --git a/features/onboarding/impl/src/main/res/values-uz/translations.xml b/features/onboarding/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..b69af4adb7
--- /dev/null
+++ b/features/onboarding/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Qo\'lda tizimga kiring"
+ "QR kod bilan tizimga kiring"
+ "Hisob yaratish"
+ "Eng tezkor %1$sga xush kelibsiz. Tezlik va oddylik uchun super zaryadlangan."
+ "%1$sga Xush kelibsiz. Tezlik va oddylik uchun o\'ta zaryadlangan."
+ "Elementingizda bo\'ling"
+
diff --git a/features/poll/impl/src/main/res/values-nl/translations.xml b/features/poll/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..e6c99c1201
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,19 @@
+
+
+ "Optie toevoegen"
+ "Resultaten pas weergeven nadat de peiling is afgelopen"
+ "Stemmen verbergen"
+ "Optie %1$d"
+ "Je wijzigingen zijn niet opgeslagen. Weet je zeker dat je terug wilt gaan?"
+ "Vraag of onderwerp"
+ "Waar gaat de peiling over?"
+ "Peiling maken"
+ "Weet je zeker dat je deze peiling wilt verwijderen?"
+ "Peiling verwijderen"
+ "Peiling wijzigen"
+ "Kan geen actieve peilingen vinden."
+ "Kan geen eerdere peilingen vinden."
+ "Actief"
+ "Afgelopen"
+ "Peilingen"
+
diff --git a/features/poll/impl/src/main/res/values-pt-rBR/translations.xml b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml
index 058dab7d42..90720720cd 100644
--- a/features/poll/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml
@@ -7,6 +7,12 @@
"Pergunta ou tópico"
"Sobre o que é a enquete?"
"Criar enquete"
+ "Tem certeza de que quer deletar esta enquete?"
"Excluir Enquete"
"Editar enquete"
+ "Não foi possível encontrar nenhuma enquete em andamento."
+ "Não foi possível encontrar nenhuma enquete anterior."
+ "Em andamento"
+ "Anteriores"
+ "Enquetes"
diff --git a/features/poll/impl/src/main/res/values-uz/translations.xml b/features/poll/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..ee41d67459
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Variant qo\'shish"
+ "Natijalarni faqat soʻrov tugagandan keyin koʻrsatish"
+ "Ovozlarni yashirish"
+ "Variant%1$d"
+ "Savol yoki mavzu"
+ "So\'rovnoma nima haqida?"
+ "So‘rovnoma yaratish"
+ "So‘rovnomani tahrirlash"
+
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index ead4515360..ec265d739e 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -90,6 +90,7 @@ dependencies {
testImplementation(projects.features.ftue.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
+ testImplementation(projects.features.logout.test)
testImplementation(projects.features.roomlist.test)
testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.pushproviders.test)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
index 58f7ac7c02..bb0252e04d 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
@@ -21,5 +21,6 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
+ data class SetSimplifiedSlidingSyncEnabled(val isEnabled: Boolean) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
index 8295b815c9..7b95463ac8 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
@@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap
import io.element.android.appconfig.ElementCallConfig
+import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
@@ -55,6 +56,7 @@ class DeveloperSettingsPresenter @Inject constructor(
private val rageshakePresenter: RageshakePreferencesPresenter,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
+ private val logoutUseCase: LogoutUseCase,
) : Presenter {
@Composable
override fun present(): DeveloperSettingsState {
@@ -75,6 +77,9 @@ class DeveloperSettingsPresenter @Inject constructor(
val customElementCallBaseUrl by appPreferencesStore
.getCustomElementCallBaseUrlFlow()
.collectAsState(initial = null)
+ val isSimplifiedSlidingSyncEnabled by appPreferencesStore
+ .isSimplifiedSlidingSyncEnabledFlow()
+ .collectAsState(initial = false)
LaunchedEffect(Unit) {
FeatureFlags.entries
@@ -114,6 +119,10 @@ class DeveloperSettingsPresenter @Inject constructor(
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
}
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
+ is DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled -> coroutineScope.launch {
+ appPreferencesStore.setSimplifiedSlidingSyncEnabled(event.isEnabled)
+ logoutUseCase.logout(ignoreSdkError = true)
+ }
}
}
@@ -127,6 +136,7 @@ class DeveloperSettingsPresenter @Inject constructor(
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
validator = ::customElementCallUrlValidator,
),
+ isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled,
eventSink = ::handleEvents
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
index 9a12823686..91cf7051c3 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
@@ -27,6 +27,7 @@ data class DeveloperSettingsState(
val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncData,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
+ val isSimpleSlidingSyncEnabled: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
index fb93a63ffc..01c2971777 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
@@ -39,6 +39,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider = AsyncData.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
+ isSimplifiedSlidingSyncEnabled: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState(
features = aFeatureUiModelList(),
@@ -46,6 +47,7 @@ fun aDeveloperSettingsState(
cacheSize = AsyncData.Success("1.2 MB"),
clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState,
+ isSimpleSlidingSyncEnabled = isSimplifiedSlidingSyncEnabled,
eventSink = eventSink,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
index dd684fcaaa..f46637e6cb 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
@@ -26,6 +26,7 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
+import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -60,6 +61,14 @@ fun DeveloperSettingsView(
title = "Configure tracing",
onClick = onOpenConfigureTracing,
)
+ PreferenceSwitch(
+ title = "Enable Simplified Sliding Sync",
+ subtitle = "When toggled you'll be logged out of the app and will need to log in again.",
+ isChecked = state.isSimpleSlidingSyncEnabled,
+ onCheckedChange = {
+ state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(it))
+ }
+ )
}
PreferenceCategory(title = "Showkase") {
PreferenceText(
diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml
index 3013315b21..01f0749846 100644
--- a/features/preferences/impl/src/main/res/values-de/translations.xml
+++ b/features/preferences/impl/src/main/res/values-de/translations.xml
@@ -1,11 +1,14 @@
+ "Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst."
+ "Verbessere dein Anruferlebnis"
"Wähle aus, wie du Benachrichtigungen erhalten möchtest"
"Entwickler-Modus"
"Aktivieren, um Zugriff auf Features und Funktionen für Entwickler zu aktivieren."
"Benutzerdefinierte Element-Aufruf-Basis-URL"
"Lege eine eigene Basis-URL für Element Call fest."
"Ungültige URL, bitte stelle sicher, dass du das Protokoll (http/https) und die richtige Adresse angibst."
+ "Anbieter für Push-Benachrichtigungen"
"Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben."
"Lesebestätigungen"
"Wenn diese Option deaktiviert ist, werden Ihre Lesebestätigungen an niemanden gesendet. Du erhältst weiterhin Lesebestätigungen von anderen Benutzern."
diff --git a/features/preferences/impl/src/main/res/values-nl/translations.xml b/features/preferences/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..6296b409b2
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,52 @@
+
+
+ "Kies hoe je meldingen wilt ontvangen"
+ "Ontwikkelaarsmodus"
+ "Schakel in om toegang te krijgen tot tools en functies voor ontwikkelaars."
+ "Aangepaste basis-URL voor Element Call"
+ "Stel een aangepaste basis-URL in voor Element Call."
+ "Ongeldige URL, zorg ervoor dat je het protocol (http/https) en het juiste adres invult."
+ "Schakel de uitgebreide tekstverwerker uit om Markdown handmatig te typen."
+ "Leesbevestigingen"
+ "Indien uitgeschakeld worden er geen leesbevestigingen verstuurd. Je ontvangt nog steeds leesbevestigingen van andere gebruikers."
+ "Aanwezigheid delen"
+ "Indien uitgeschakeld kun je geen leesbevestigingen en typmeldingen verzenden of ontvangen."
+ "Schakel optie in om de berichtbron in de tijdlijn te bekijken."
+ "Deblokkeren"
+ "Je zult alle berichten van hen weer kunnen zien."
+ "Gebruiker deblokkeren"
+ "Deblokkeren…"
+ "Weergavenaam"
+ "Je weergavenaam"
+ "Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd."
+ "Kan profiel niet bijwerken"
+ "Profiel bewerken"
+ "Profiel bijwerken…"
+ "Aanvullende instellingen"
+ "Audio- en videogesprekken"
+ "Configuratie komt niet overeen"
+ "We hebben de instellingen voor meldingen vereenvoudigd, zodat je de opties gemakkelijker kunt vinden. Sommige instellingen die je in het verleden hebt aangepast, worden hier niet getoond, maar zijn nog steeds actief.
+
+Als je doorgaat, kunnen sommige van je instellingen veranderen."
+ "Directe chats"
+ "Aangepaste instelling per chat"
+ "Er is een fout opgetreden bij het bijwerken van de meldingsinstelling."
+ "Alle berichten"
+ "Alleen vermeldingen en trefwoorden"
+ "Bij directe chats, stuur me een melding voor"
+ "Bij groep chats, stuur me een melding voor"
+ "Meldingen op dit apparaat inschakelen"
+ "De configuratie is niet gecorrigeerd. Probeer het opnieuw."
+ "Groep chats"
+ "Uitnodigingen"
+ "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in sommige kamers krijg je mogelijk geen meldingen."
+ "Vermeldingen"
+ "Alles"
+ "Vermeldingen"
+ "Stuur me een melding voor"
+ "Stuur me een melding bij @kamer"
+ "Wijzig je %1$s om meldingen te ontvangen."
+ "systeeminstellingen"
+ "Systeemmeldingen uitgeschakeld"
+ "Meldingen"
+
diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml
index 021d7595f1..6bc66cd63c 100644
--- a/features/preferences/impl/src/main/res/values-pl/translations.xml
+++ b/features/preferences/impl/src/main/res/values-pl/translations.xml
@@ -3,22 +3,29 @@
"Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."
"Popraw jakość swoich rozmów"
"Wybierz sposób otrzymywania powiadomień"
- "Tryb dewelopera"
+ "Tryb programisty"
"Włącz, aby uzyskać dostęp do funkcji dla deweloperów."
"Własny bazowy URL dla połączeń Element"
"Ustaw własny bazowy URL dla połączeń Element"
"Nieprawidłowy adres URL, upewnij się, że zawiera protokół (http/https) i poprawny adres."
+ "Dostawca powiadomień push"
"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."
+ "Potwierdzenia odczytania"
+ "Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane."
+ "Udostępnij obecność"
+ "Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania."
"Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."
+ "Nie blokujesz żadnych użytkowników"
"Odblokuj"
"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."
"Odblokuj użytkownika"
+ "Odblokowuję…"
"Wyświetlana nazwa"
"Twoja wyświetlana nazwa"
"Wystąpił nieznany błąd przez co nie można było zmienić informacji."
"Nie można zaktualizować profilu"
"Edytuj profil"
- "Aktualizowanie profilu…"
+ "Aktualizuję profil…"
"Dodatkowe ustawienia"
"Połączenia audio i wideo"
"Niezgodność konfiguracji"
@@ -46,4 +53,6 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz."
"ustawienia systemowe"
"Powiadomienia systemowe wyłączone"
"Powiadomienia"
+ "Rozwiązywanie problemów"
+ "Powiadomienia rozwiązywania problemów"
diff --git a/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml
index 2aa9704981..6255c7c2c9 100644
--- a/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,10 +3,18 @@
"Escolha como receber notificações"
"Modo de desenvolvedor"
"Habilite para ter acesso a recursos e funcionalidades para desenvolvedores."
+ "URL inválida, por favor verifique se o protocolo (http/https) e o endereço correto estão presentes."
"Desative o editor de rich text para digitar Markdown manualmente."
+ "Confirmações de leitura"
+ "Se desligado, suas confirmações de leitura não serão enviadas para ninguém. Você ainda receberá confirmações de leitura de outros usuários."
+ "Compartilhar presença"
+ "Se desligado, você não poderá enviar ou receber confirmações de leitura ou notificações de digitação."
+ "Ativar a opção de visualizar o fonte da mensagem na linha do tempo."
+ "Você não tem usuários bloqueados"
"Desbloquear"
"Você poderá ver todas as mensagens deles novamente."
"Desbloquear usuário"
+ "Desbloqueando…"
"Nome de exibição"
"Seu nome de exibição"
"Um erro desconhecido foi encontrado e as informações não puderam ser alteradas."
@@ -29,11 +37,13 @@ Se você continuar, algumas de suas configurações poderão mudar."
"Ativar notificações neste dispositivo"
"A configuração não foi corrigida, tente novamente."
"Bate-papos em grupo"
+ "Convites"
"Menções"
"Todos"
"Menções"
"Me notifique para"
"Notifique-me em @room"
+ "Para receber notificações, altere seu %1$s."
"configurações do sistema"
"Notificações do sistema desativadas"
"Notificações"
diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml
index a4ce5bc50c..aa765bc080 100644
--- a/features/preferences/impl/src/main/res/values-sv/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sv/translations.xml
@@ -1,11 +1,14 @@
+ "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."
+ "Förbättra din samtalsupplevelse"
"Välj hur du vill ta emot aviseringar"
"Utvecklarläge"
"Aktivera för att ha tillgång till funktionalitet för utvecklare."
"Anpassad bas-URL för Element Call"
"Ange en anpassad bas-URL för Element Call."
"Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress."
+ "Pushnotisleverantör"
"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."
"Läskvitton"
"Om det är avstängt kommer dina läskvitton inte att skickas till någon. Du kommer fortfarande att få läskvitton från andra användare."
diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml
index 0fdf2a67ad..8905fb3366 100644
--- a/features/preferences/impl/src/main/res/values-uk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-uk/translations.xml
@@ -1,11 +1,13 @@
+ "Щоб ніколи не пропустити важливий дзвінок, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."
"Виберіть спосіб отримання сповіщень"
"Режим розробника"
"Увімкніть доступ до функцій і можливостей для розробників."
"Користувацька URL-адреса Element Call"
"Встановіть URL-адресу для Element Call."
"Неправильна URL-адреса, будь ласка, переконайтеся, що ви вказали протокол (http/https) та правильну адресу."
+ "Постачальник push-сповіщень"
"Вимкніть редактор розширеного тексту, щоб вводити Markdown вручну."
"Читати журнали"
"Якщо вимкнено, ваші сповіщення про прочитання нікому не надсилатимуться. Ви все одно отримуватимете сповіщення про прочитання від інших користувачів."
@@ -50,4 +52,6 @@
"системні налаштування"
"Системні сповіщення вимкнені"
"Сповіщення"
+ "Усунення несправностей"
+ "Усунення неполадок сповіщень"
diff --git a/features/preferences/impl/src/main/res/values-uz/translations.xml b/features/preferences/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..21104e4ab8
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,42 @@
+
+
+ "Bildirishnomalarni qanday qabul qilishni tanlang"
+ "Dasturchi rejimi"
+ "Ishlab chiquvchilar uchun xususiyatlar va funksiyalarga kirishni yoqing."
+ "Maxsus element qo‘ng‘iroqlar bazasi URL manzili"
+ "Boy matn muharriri o\'chiring Markdown bilan qo\'lda yozish uchun"
+ "Blokdan chiqarish"
+ "Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi."
+ "Foydalanuvchini blokdan chiqarish"
+ "Ko\'rsatiladigan ism"
+ "Ismingizni ko\'rsating"
+ "Noma\'lum xatolik yuz berdi va ma\'lumotni o\'zgartirib bo\'lmadi."
+ "Profilni yangilab bo‘lmadi"
+ "Profilni tahrirlash"
+ "Profil yangilanmoqda…"
+ "Qo\'shimcha sozlamalar"
+ "Audio va video qo\'ng\'iroqlar"
+ "Konfiguratsiya mos kelmasligi"
+ "Variantlarni topishni osonlashtirish uchun bildirishnomalar sozlamalarini soddalashtirdik. Ilgari siz tanlagan baʼzi shaxsiy sozlamalar bu yerda koʻrsatilmaydi, lekin ular hali ham faol.
+
+Davom ettirsangiz, baʼzi sozlamalaringiz oʻzgarishi mumkin."
+ "To\'g\'ridan-to\'g\'ri suhbatlar"
+ "Har bir suhbat uchun moslashtirilgan sozlama"
+ "Bildirishnoma sozlamalarini yangilashda xatolik yuz berdi."
+ "Barcha xabarlar"
+ "Faqat eslatmalar va kalit so\'zlar"
+ "To\'g\'ridan-to\'g\'ri suhbats, menga xabar bering"
+ "Guruh suhbatlarida menga xabar bering"
+ "Ushbu qurilmada bildirishnomalarni yoqing"
+ "Konfiguratsiya tuzatilmadi, qayta urinib ko\'ring."
+ "Guruh suhbatlari"
+ "Eslatmalar"
+ "Hammasi"
+ "Eslatmalar"
+ "Menga xabar bering"
+ "Menga @room orqali xabar bering"
+ "Bildirishnomalarni olish uchun, iltimos, o\'zingizni %1$singizni o\'zgartiring."
+ "tizim sozlamalari"
+ "Tizim bildirishnomalari o\'chirilgan"
+ "Bildirishnomalar"
+
diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml
index 13e52fdcda..6e74db47c2 100644
--- a/features/preferences/impl/src/main/res/values-zh/translations.xml
+++ b/features/preferences/impl/src/main/res/values-zh/translations.xml
@@ -1,9 +1,11 @@
+ "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。"
+ "提升通话体验"
"选择如何接收通知"
"开发者模式"
"允许开发人员访问特性和功能。"
- "自定义 Element 通话 URL"
+ "自定义 Element Call URL"
"为 Element 通话设置根 URL。"
"URL 无效,请确保包含协议(http/https)和正确的地址。"
"通知推送提供者"
@@ -12,10 +14,10 @@
"如果关闭,您的已读回执将不会发送给别人。您仍能收到别人的已读回执。"
"分享在线状态"
"如果关闭,您将无法发送或接收已读回执、输入通知"
- "启用在时间轴中查看消息来源的选项。"
+ "启用在时间轴中查看消息源码的选项。"
"您没有屏蔽用户"
"解封"
- "你可以重新接收他们的消息。"
+ "可以重新接收他们的消息。"
"解封用户"
"正在解除屏蔽……"
"显示名称"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
index 113125d950..9fb1c8b37f 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
@@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.ElementCallConfig
+import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
@@ -35,6 +36,8 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -55,6 +58,7 @@ class DeveloperSettingsPresenterTest {
assertThat(initialState.cacheSize).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
+ assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
val loadedState = awaitItem()
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
assertThat(loadedState.rageshakeState.isSupported).isTrue()
@@ -160,6 +164,30 @@ class DeveloperSettingsPresenterTest {
}
}
+ @Test
+ fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
+ val logoutCallRecorder = lambdaRecorder { "" }
+ val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
+ val preferences = InMemoryAppPreferencesStore()
+ val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
+
+ initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
+ assertThat(awaitItem().isSimpleSlidingSyncEnabled).isTrue()
+ assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
+ logoutCallRecorder.assertions().isCalledOnce()
+
+ initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
+ assertThat(awaitItem().isSimpleSlidingSyncEnabled).isFalse()
+ assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
+ logoutCallRecorder.assertions().isCalledExactly(times = 2)
+ }
+ }
+
private fun createDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
@@ -167,6 +195,7 @@ class DeveloperSettingsPresenterTest {
rageshakePresenter: DefaultRageshakePreferencesPresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
buildMeta: BuildMeta = aBuildMeta(),
+ logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" })
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
featureFlagService = featureFlagService,
@@ -175,6 +204,7 @@ class DeveloperSettingsPresenterTest {
rageshakePresenter = rageshakePresenter,
appPreferencesStore = preferencesStore,
buildMeta = buildMeta,
+ logoutUseCase = logoutUseCase,
)
}
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
index 7288b76d3c..aa993c39bc 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
@@ -109,6 +109,19 @@ class DeveloperSettingsViewTest {
rule.onNodeWithText("Clear cache").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache)
}
+
+ @Config(qualifiers = "h1500dp")
+ @Test
+ fun `clicking on the simplified sliding sync switch emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setDeveloperSettingsView(
+ state = aDeveloperSettingsState(
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.onNodeWithText("Enable Simplified Sliding Sync").performClick()
+ eventsRecorder.assertSingle(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
+ }
}
private fun AndroidComposeTestRule.setDeveloperSettingsView(
diff --git a/features/rageshake/api/src/main/res/values-nl/translations.xml b/features/rageshake/api/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..a6d1d2e369
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-nl/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$s crashte de laatste keer dat het werd gebruikt. Wil je een crashrapport met ons delen?"
+ "Het lijkt erop dat je gefrustreerd de telefoon hebt geschud. Wil je het scherm openen om een bug te rapporteren?"
+ "Schudden uit woede"
+ "Drempel voor detectie"
+
diff --git a/features/rageshake/api/src/main/res/values-uz/translations.xml b/features/rageshake/api/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..96032fd65f
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-uz/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"
+ "Siz hafsalasi pir bo\'lib telefonni silkitayotganga o\'xshaysiz. Xatolar haqida hisobot ekranini ochmoqchimisiz?"
+ "G\'azablanish"
+ "Aniqlash chegarasi"
+
diff --git a/features/rageshake/api/src/main/res/values-zh/translations.xml b/features/rageshake/api/src/main/res/values-zh/translations.xml
index 38942e2f1a..34a643ceab 100644
--- a/features/rageshake/api/src/main/res/values-zh/translations.xml
+++ b/features/rageshake/api/src/main/res/values-zh/translations.xml
@@ -1,6 +1,6 @@
- "%1$s 上次使用时崩溃了。你想和我们分享崩溃报告吗?"
+ "%1$s 上次使用时崩溃了。想和我们分享崩溃报告吗?"
"你似乎愤怒地摇晃了手机。想要打开 Bug 报告页面吗?"
"摇一摇"
"检测阈值"
diff --git a/features/rageshake/impl/src/main/res/values-nl/translations.xml b/features/rageshake/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..dbf62aade8
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Schermafbeelding bijvoegen"
+ "Je mag contact met mij opnemen als je nog vervolg vragen hebt."
+ "Neem contact met mij op"
+ "Schermafbeelding bewerken"
+ "Beschrijf het probleem. Wat heb je gedaan? Wat had je verwacht? Wat is er daadwerkelijk gebeurd. Beschrijf het zo gedetailleerd mogelijk."
+ "Beschrijf het probleem…"
+ "Geeft de beschrijving in het Engels indien mogelijk."
+ "De beschrijving is te kort, geef meer details over wat er is gebeurd. Bedankt!"
+ "Crashlogboeken verzenden"
+ "Logboeken toestaan"
+ "Schermafbeelding verzenden"
+ "Er worden logbestanden bij uw bericht gevoegd om er zeker van te zijn dat alles goed werkt. Als u uw bericht zonder logbestanden wilt verzenden, schakelt u deze instelling uit."
+ "%1$s crashte de laatste keer dat het werd gebruikt. Wil je een crashrapport met ons delen?"
+ "Logboeken weergeven"
+
diff --git a/features/rageshake/impl/src/main/res/values-pl/translations.xml b/features/rageshake/impl/src/main/res/values-pl/translations.xml
index 04bf123498..36b86289b2 100644
--- a/features/rageshake/impl/src/main/res/values-pl/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-pl/translations.xml
@@ -1,12 +1,13 @@
- "Dołącz zrzut ekranu"
- "Możecie skontaktować się ze mną, jeśli macie jakiekolwiek dodatkowe pytania."
- "Napisz do mnie"
+ "Załącz zrzut ekranu"
+ "Zezwól na kontakt ze mną, jeśli są jakiekolwiek dodatkowe pytania."
+ "Kontakt ze mną"
"Edytuj zrzut ekranu"
"Opisz problem. Co zrobiłeś? Czego oczekiwałeś? Co się stało zamiast tego. Podaj jak najwięcej szczegółów."
"Opisz problem…"
"Jeśli to możliwe, napisz zgłoszenje w języku angielskim."
+ "Opis jest zbyt krótki, podaj więcej szczegółów na temat tego co się stało. Dzięki!"
"Wyślij logi awarii"
"Zezwól na logi"
"Wyślij zrzut ekranu"
diff --git a/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml
index d046391f6a..885c9a6fe6 100644
--- a/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml
@@ -12,4 +12,5 @@
"Enviar captura de tela"
"Os registros serão incluídos com sua mensagem para garantir que tudo esteja funcionando corretamente. Para enviar sua mensagem sem registros, desative essa configuração."
"%1$s fechou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?"
+ "Ver registros"
diff --git a/features/rageshake/impl/src/main/res/values-uz/translations.xml b/features/rageshake/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..474c775cb0
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Ekran tasvirini biriktirish"
+ "Agar sizda keyingi savollar bo\'lsa, men bilan bog\'lanishingiz mumkin."
+ "Men bilan bog\'laning"
+ "Ekran tasvirini tahrirlash"
+ "Iltimos, muammoni tasvirlab bering. Nima qildingiz? Nima bo\'lishini kutgan edingiz? Aslida nima bo\'ldi. Iltimos, iloji boricha batafsilroq ma\'lumot bering."
+ "Muammoni tasvirlab bering…"
+ "Iloji bo\'lsa, tavsifni ingliz tilida yozing."
+ "Buzilish jurnallarini yuboring"
+ "Jurnallarga ruxsat bering"
+ "Ekran tasvirini yuboring "
+ "Har bir narsa to\'ri ishlayotganiga ishonch hosil qilish uchun xabaringizga jurnallar kiritiladi. Xabarni jurnallarsiz yuborish uchun ushbu sozlamani oʻchiring."
+ "%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"
+
diff --git a/features/rageshake/impl/src/main/res/values-zh/translations.xml b/features/rageshake/impl/src/main/res/values-zh/translations.xml
index 7fbf6bd644..59da17e82d 100644
--- a/features/rageshake/impl/src/main/res/values-zh/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-zh/translations.xml
@@ -12,6 +12,6 @@
"允许日志"
"发送屏幕截图"
"为确认一切正常运行,您的消息中将包含日志。如要发送不带日志的消息,请关闭此设置。"
- "%1$s 上次使用时崩溃了。你想和我们分享崩溃报告吗?"
+ "%1$s 上次使用时崩溃了。想和我们分享崩溃报告吗?"
"查看日志"
diff --git a/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..7f830ab944
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nie udało się uzyskać aliasu pokoju."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml
new file mode 100644
index 0000000000..8bcfb3f809
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Misslyckades med att slå upp rumsalias."
+
diff --git a/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..0f5d40f6d8
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Не вдалося розв\'язати псевдонім кімнати."
+
diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml
index 2c1886ce40..57874ff30b 100644
--- a/features/roomdetails/impl/src/main/res/values-el/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml
@@ -63,7 +63,7 @@
"Δεν θα μπορεί να συμμετέχει ξανά σε αυτό το δωμάτιο εάν προσκληθεί."
"Θες σίγουρα να αποκλείσεις αυτό το μέλος;"
"Δεν υπάρχουν αποκλεισμένοι χρήστες σε αυτό το δωμάτιο."
- "Αποκλεισμός του χρήστη %1$s"
+ "Αποκλεισμός %1$s"
- "%1$d άτομο"
- "%1$d άτομα"
diff --git a/features/roomdetails/impl/src/main/res/values-nl/translations.xml b/features/roomdetails/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..117ef56b9f
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,64 @@
+
+
+ "Er is een fout opgetreden bij het bijwerken van de meldingsinstelling."
+ "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in sommige kamers krijg je mogelijk geen meldingen."
+ "Peilingen"
+ "Iedereen"
+ "Je kunt deze actie niet ongedaan maken. Je bevordert deze gebruiker tot hetzelfde machtsniveau als jij."
+ "Beheerder toevoegen?"
+ "Jezelf degraderen?"
+ "Leden"
+ "Onderwerp toevoegen"
+ "Reeds lid"
+ "Reeds uitgenodigd"
+ "Kamer bewerken"
+ "Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd."
+ "Kan kamer niet bijwerken"
+ "Berichten zijn beveiligd met sloten. Alleen jij en de ontvangers hebben de unieke sleutels om ze te ontgrendelen."
+ "Berichtversleuteling ingeschakeld"
+ "Er is een fout opgetreden bij het laden van de meldingsinstellingen."
+ "Het dempen van deze kamer is mislukt. Probeer het opnieuw."
+ "Het dempen opheffen voor deze kamer is mislukt. Probeer het opnieuw."
+ "Mensen uitnodigen"
+ "Gesprek verlaten"
+ "Ruimte verlaten"
+ "Aangepast"
+ "Standaard"
+ "Meldingen"
+ "Naam van de kamer"
+ "Beveiliging"
+ "Kamer delen"
+ "Onderwerp"
+ "Kamer bijwerken…"
+
+ - "%1$d persoon"
+ - "%1$d personen"
+
+ "Verwijderen uit kamer"
+ "Lid verwijderen en verbannen"
+ "Alleen lid verwijderen"
+ "Lid verwijderen en toekomstige deelname verbieden?"
+ "Ontbannen"
+ "Profiel bekijken"
+ "Verbannen"
+ "Leden"
+ "In behandeling"
+ "%1$s wordt verwijderd…"
+ "Beheerder"
+ "Moderator"
+ "Kamerleden"
+ "Aanpassen toestaan"
+ "Als je dit inschakelt, wordt je standaardinstelling overschreven"
+ "Stuur me een melding in deze chat voor"
+ "Je kunt het wijzigen in je %1$s."
+ "algemene instellingen"
+ "Standaardinstelling"
+ "Aanpassingen verwijderen"
+ "Er is een fout opgetreden bij het laden van de meldingsinstellingen."
+ "Het herstellen van de standaardmeldingen is mislukt. Probeer het opnieuw."
+ "Het instellen van de meldingen is mislukt. Probeer het opnieuw."
+ "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in deze kamer krijg je geen meldingen."
+ "Alle berichten"
+ "Alleen vermeldingen en trefwoorden"
+ "In deze kamer, stuur me een melding voor"
+
diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml
index 569e167d92..80195a14ea 100644
--- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml
@@ -3,10 +3,41 @@
"Wystąpił błąd podczas aktualizacji ustawienia powiadomień."
"Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z niektórych pokoi."
"Ankiety"
+ "Tylko administratorzy"
+ "Banowanie osób"
+ "Usuwanie wiadomości"
"Wszyscy"
+ "Zapraszanie osób"
+ "Moderacja członków"
+ "Wiadomości i zawartość"
+ "Administratorzy i moderatorzy"
+ "Usuwanie osób"
+ "Zmień awatar pokoju"
+ "Szczegóły pokoju"
+ "Zmień nazwę pokoju"
+ "Zmień temat pokoju"
+ "Wysyłanie wiadomości"
+ "Edytuj administratorów"
+ "Tej akcji nie będzie można cofnąć. Promujesz użytkownika, który będzie posiadał takie same uprawnienia jak Ty."
+ "Dodać administratora?"
+ "Zdegraduj"
+ "Nie będzie można cofnąć tej zmiany, jeśli się zdegradujesz. Jeśli jesteś ostatnim uprzywilejowanym użytkownikiem w pokoju, nie będziesz w stanie odzyskać uprawnień."
+ "Zdegradować siebie?"
+ "%1$s (Oczekujące)"
+ "(Oczekujący)"
+ "Administratorzy automatycznie mają uprawnienia moderatora"
+ "Edytuj moderatorów"
+ "Administratorzy"
+ "Moderatorzy"
+ "Członków"
+ "Masz niezapisane zmiany."
+ "Zapisać zmiany?"
"Dodaj temat"
"Jest już członkiem"
"Już zaproszony"
+ "Szyfrowany"
+ "Nieszyfrowany"
+ "Pokój publiczny"
"Edytuj pokój"
"Wystąpił nieznany błąd i nie można było zmienić informacji."
"Nie można zaktualizować pokoju"
@@ -21,18 +52,40 @@
"Niestandardowy"
"Domyślny"
"Powiadomienia"
+ "Role i uprawnienia"
"Nazwa pokoju"
"Bezpieczeństwo"
"Udostępnij pokój"
+ "Informacje pokoju"
"Temat"
"Aktualizuję pokój…"
+ "Zbanuj"
+ "Nie będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni."
+ "Czy na pewno chcesz zbanować tego członka?"
+ "W tym pokoju nie ma zbanowanych użytkowników."
+ "Banowanie %1$s"
- "%1$d osoba"
- "%1$d osoby"
- "%1$d osób"
+ "Usuń i zbanuj członka"
+ "Usuń z pokoju"
+ "Usuń i zbanuj członka"
+ "Tylko usuń członka"
+ "Usunąć członka i zablokować możliwość dołączenia w przyszłości?"
+ "Odbanuj"
+ "Będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni."
+ "Odbanuj użytkownika"
+ "Wyświetl profil"
+ "Zbanowanych"
+ "Członków"
"Oczekiwanie"
+ "Usuwanie %1$s…"
+ "Administrator"
+ "Moderator"
"Członkowie pokoju"
+ "Odbanowanie %1$s"
"Zezwalaj na ustawienia niestandardowe"
"Włączenie tej opcji nadpisze ustawienie domyślne"
"Powiadamiaj mnie o tym czacie przez"
@@ -47,4 +100,18 @@
"Wszystkie wiadomości"
"Tylko wzmianki i słowa kluczowe"
"W tym pokoju, powiadamiaj mnie przez"
+ "Administratorzy"
+ "Zmień moją rolę"
+ "Zdegraduj do członka"
+ "Zdegraduj do moderatora"
+ "Moderacja członków"
+ "Wiadomości i zawartość"
+ "Moderatorzy"
+ "Uprawnienia"
+ "Resetuj uprawnienia"
+ "Po zresetowaniu uprawnień utracisz bieżące ustawienia."
+ "Zresetować uprawnienia?"
+ "Role"
+ "Szczegóły pokoju"
+ "Role i uprawnienia"
diff --git a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml
index 4989168201..f7e4d5f9e0 100644
--- a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,7 +1,29 @@
"Ocorreu um erro ao atualizar a configuração de notificação."
+ "Enquetes"
+ "Somente administradores"
+ "Banir pessoas"
+ "Remover mensagens"
"Todos"
+ "Convidar pessoas"
+ "Moderação de membros"
+ "Mensagens e conteúdo"
+ "Administradores e moderadores"
+ "Remover pessoas"
+ "Alterar avatar da sala"
+ "Detalhes da sala"
+ "Alterar nome da sala"
+ "Alterar tópico da sala"
+ "Enviar mensagens"
+ "Editar administradores"
+ "Adicionar administrador?"
+ "Editar moderadores"
+ "Administradores"
+ "Moderadores"
+ "Membros"
+ "Você tem alterações não salvas."
+ "Salvar alterações?"
"Adicionar tópico"
"Já é membro"
"Já foi convidado"
@@ -19,17 +41,35 @@
"Personalizado"
"Padrão"
"Notificações"
+ "Cargos e permissões"
"Nome da sala"
"Segurança"
"Compartilhar sala"
"Tópico"
"Atualizando a sala…"
+ "Banir"
+ "Tem certeza de que quer banir este membro?"
+ "Banindo %1$s"
- "%1$d pessoa"
- "%1$d pessoas"
+ "Remover e banir membro"
+ "Remover da sala"
+ "Remover e banir membro"
+ "Somente remover membro"
+ "Remover membro e banir de entrar novamente no futuro?"
+ "Desbanir"
+ "Desbanir usuário"
+ "Ver perfil"
+ "Banidos"
+ "Membros"
"Pendente"
+ "Removendo %1$s…"
+ "Administrador"
+ "Moderador"
"Membros da sala"
+ "Desbanindo %1$s"
"Permitir configuração personalizada"
"Ativar isso substituirá sua configuração padrão"
"Me notifique nesta conversa para"
@@ -43,4 +83,15 @@
"Todas as mensagens"
"Somente menções e palavras-chave"
"Nesta sala, notifique-me para"
+ "Administradores"
+ "Alterar meu cargo"
+ "Moderação de membros"
+ "Mensagens e conteúdo"
+ "Moderadores"
+ "Permissões"
+ "Redefinir permissões"
+ "Redefinir permissões?"
+ "Cargos"
+ "Detalhes da sala"
+ "Cargos e permissões"
diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
index aec11eed32..eed1cfcbb1 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -25,6 +25,7 @@
"Degradera dig själv?"
"%1$s (Väntar)"
"(Väntar)"
+ "Administratörer har automatiskt moderatorbehörighet"
"Redigera moderatorer"
"Administratörer"
"Moderatorer"
@@ -34,6 +35,9 @@
"Lägg till ämne"
"Redan medlem"
"Redan inbjuden"
+ "Krypterat"
+ "Inte krypterat"
+ "Offentligt rum"
"Redigera rummet"
"Ett okänt fel uppstod och informationen kunde inte ändras."
"Kunde inte uppdatera rummet"
diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
index b06ad30097..87251699c1 100644
--- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
@@ -23,6 +23,9 @@
"Понизити"
"Ви не зможете скасувати цю зміну, оскільки ви знижуєте себе, якщо ви останній привілейований користувач у кімнаті, відновити привілеї буде неможливо."
"Понизити себе?"
+ "%1$s (Очікується)"
+ "(Очікується)"
+ "Адміністратори автоматично мають права модератора"
"Керувати модераторами"
"Адміністратори"
"Модератори"
@@ -32,6 +35,9 @@
"Додати тему"
"Уже учасник"
"Уже запрошені"
+ "Зашифровано"
+ "Не зашифровано"
+ "Публічна кімната"
"Редагувати кімнату"
"Сталася невідома помилка, й інформацію не вдалося змінити."
"Не вдалося оновити кімнату"
@@ -50,6 +56,7 @@
"Назва кімнати"
"Безпека"
"Поділитися кімнатою"
+ "Інформація про кімнату"
"Тема"
"Оновлення кімнати…"
"Заблокувати"
diff --git a/features/roomdetails/impl/src/main/res/values-uz/translations.xml b/features/roomdetails/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..a7d0642bed
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,45 @@
+
+
+ "Bildirishnoma sozlamalarini yangilashda xatolik yuz berdi."
+ "Har kim"
+ "Mavzu qo\'shish"
+ "Allaqachon a\'zo"
+ "Allaqachon taklif qilingan"
+ "Xonani tahrirlash"
+ "Nomaʼlum xatolik yuz berdi va maʼlumotni oʻzgartirib boʻlmadi."
+ "Xonani yangilab bo‘lmadi"
+ "Xabarlar qulflar bilan himoyalangan. Faqat siz va qabul qiluvchilar ularni qulfdan chiqarish uchun noyob kalitlarga ega."
+ "Xabarni shifrlash yoqilgan"
+ "Bildirishnoma sozlamalarini yuklashda xatolik yuz berdi."
+ "Bu xona ovozini o‘chirib bo‘lmadi, qayta urinib ko‘ring."
+ "Bu xonaning ovozi yoqilmadi, qayta urinib ko‘ring."
+ "Odamlarni taklif qiling"
+ "Xonani tark etish "
+ "Maxsus"
+ "Standart"
+ "Bildirishnomalar"
+ "Xona nomi"
+ "Xavfsizlik"
+ "Xonani baham ko\'ring"
+ "Mavzu"
+ "Xona yangilanmoqda…"
+
+ - "%1$dodam"
+ - "%1$dodamlar"
+
+ "Kutilmoqda"
+ "Xona a\'zolari"
+ "Moslashtirilgan sozlamalarga ruxsat bering"
+ "Buni yoqsangiz, standart sozlamalaringiz bekor qilinadi"
+ "Bu chatda menga xabar bering"
+ "Siz buni o\'zgartira olasiz o\'zingizning %1$sda."
+ "global sozlamalar"
+ "Standart sozlama"
+ "Maxsus sozlamani olib tashlang"
+ "Bildirishnoma sozlamalarini yuklashda xatolik yuz berdi."
+ "Standart rejimni tiklab bo‘lmadi, qaytadan urinib ko‘ring."
+ "Rejimni o‘rnatib bo‘lmadi, qayta urinib ko‘ring."
+ "Barcha xabarlar"
+ "Faqat eslatmalar va kalit so\'zlar"
+ "Bu xonada menga xabar bering"
+
diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml
index c6cf9890b3..660021d604 100644
--- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml
@@ -41,7 +41,7 @@
"编辑聊天室"
"出现未知错误,无法更改信息。"
"无法更新聊天室"
- "你的消息受加密保护,并且只有你和消息接收者拥有唯一解密密钥。"
+ "消息已加密,只有你和消息接收者拥有唯一解密密钥。"
"消息加密已启用"
"加载通知设置时出错。"
"无法将此房间静音,请重试。"
diff --git a/features/roomdirectory/impl/src/main/res/values-pl/translations.xml b/features/roomdirectory/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..80bbffed87
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Błąd wczytywania"
+ "Katalog pokoi"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..07bbad975f
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Diretório de salas"
+
diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml
index 36442d6f7e..6215b3d4e4 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -2,6 +2,8 @@
"Dein Chat-Backup ist derzeit nicht synchronisiert. Du musst deinen Wiederherstellungsschlüssel bestätigen, um Zugriff auf dein Chat-Backup zu erhalten."
"Wiederherstellungsschlüssel bestätigen."
+ "Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst."
+ "Verbessere dein Anruferlebnis"
"Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"
"Einladung ablehnen"
"Bist du sicher, dass du diese Direktnachricht von %1$s ablehnen möchtest?"
diff --git a/features/roomlist/impl/src/main/res/values-nl/translations.xml b/features/roomlist/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..a20e0a695c
--- /dev/null
+++ b/features/roomlist/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,26 @@
+
+
+ "Je chatback-up is momenteel niet gesynchroniseerd. Je moet je herstelsleutel invoeren om toegang te behouden tot je chatback-up."
+ "Voer je herstelsleutel in"
+ "Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?"
+ "Uitnodiging weigeren"
+ "Weet je zeker dat je deze privéchat met %1$s wilt weigeren?"
+ "Chat weigeren"
+ "Geen uitnodigingen"
+ "%1$s (%2$s) heeft je uitgenodigd"
+ "Dit is een eenmalig proces, bedankt voor het wachten."
+ "Je account instellen."
+ "Begin een nieuw gesprek of maak een nieuwe kamer"
+ "Ga aan de slag door iemand een bericht te sturen."
+ "Nog geen chats."
+ "Favorieten"
+ "Lage prioriteit"
+ "Personen"
+ "Kamers"
+ "Ongelezen"
+ "Chats"
+ "Markeren als gelezen"
+ "Markeren als ongelezen"
+ "Het lijkt erop dat je een nieuw apparaat gebruikt. Verifieer met een ander apparaat om toegang te krijgen tot je versleutelde berichten."
+ "Verifieer dat jij het bent"
+
diff --git a/features/roomlist/impl/src/main/res/values-pl/translations.xml b/features/roomlist/impl/src/main/res/values-pl/translations.xml
index 0ae62297a1..96d91a568f 100644
--- a/features/roomlist/impl/src/main/res/values-pl/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-pl/translations.xml
@@ -1,10 +1,10 @@
"Twoja kopia zapasowa czatu jest obecnie niezsynchronizowana. Aby zachować dostęp do kopii zapasowej czatu, musisz potwierdzić klucz odzyskiwania."
- "Potwierdź klucz odzyskiwania"
+ "Wprowadź swój klucz przywracania"
"Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."
"Popraw jakość swoich rozmów"
- "Czy na pewno chcesz odrzucić zaproszenie do dołączenia do %1$s?"
+ "Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"
"Odrzuć zaproszenie"
"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
"Odrzuć czat"
@@ -15,8 +15,26 @@
"Utwórz nową rozmowę lub pokój"
"Wyślij komuś wiadomość, aby rozpocząć."
"Brak czatów."
+ "Ulubione"
+ "Możesz dodać czat do ulubionych w ustawieniach czatu.
+Na razie możesz wyczyścić filtry, aby zobaczyć pozostałe czaty"
+ "Nie masz jeszcze ulubionych czatów"
+ "Zaproszenia"
+ "Nie masz żadnych oczekujących zaproszeń."
+ "Niski priorytet"
+ "Wyczyść filtry, aby zobaczyć pozostałe czaty"
+ "Brak czatów dla podanych kryteriów"
"Osoby"
+ "Nie masz jeszcze żadnych PW"
+ "Pokoje"
+ "Nie jesteś jeszcze w żadnym pokoju"
+ "Nieprzeczytane"
+ "Gratulacje!
+Nie masz żadnych nieprzeczytanych wiadomości!"
"Wszystkie czaty"
+ "Oznacz jako przeczytane"
+ "Oznacz jako nieprzeczytane"
+ "Przeglądaj wszystkie pokoje"
"Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."
"Potwierdź, że to Ty"
diff --git a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
index 6765199a70..5f896a6427 100644
--- a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
@@ -12,8 +12,21 @@
"Criar uma nova conversa ou sala"
"Comece enviando uma mensagem para alguém."
"Ainda não há conversas."
+ "Favoritos"
+ "Você não tem nenhuma conversa favorita ainda"
+ "Baixa prioridade"
+ "Você não tem conversas para esta seleção"
"Pessoas"
+ "Você não tem nenhum conversa privada ainda"
+ "Salas"
+ "Você não está em nenhuma sala ainda"
+ "Não lidos"
+ "Parabéns!
+Você não tem nenhuma mensagem não lida!"
"Conversas"
+ "Marcar como lido"
+ "Marcar como não lido"
+ "Navegar por todas as salas"
"Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas."
"Verifique se é você"
diff --git a/features/roomlist/impl/src/main/res/values-sv/translations.xml b/features/roomlist/impl/src/main/res/values-sv/translations.xml
index 69d5dc7cf3..4f7b26bb52 100644
--- a/features/roomlist/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-sv/translations.xml
@@ -2,6 +2,8 @@
"Din chattsäkerhetskopia är för närvarande inte synkroniserad. Du måste ange din återställningsnyckel för att behålla åtkomsten till din chattsäkerhetskopia."
"Ange din återställningsnyckel"
+ "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."
+ "Förbättra din samtalsupplevelse"
"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"
"Avböj inbjudan"
"Är du säker på att du vill avböja denna privata chatt med %1$s?"
@@ -17,6 +19,8 @@
"Du kan lägga till en chatt till dina favoriter i chattinställningarna.
För tillfället kan du avmarkera filter för att se dina andra chattar"
"Du har inga favoritchattar än"
+ "Inbjudningar"
+ "Du har inga väntande inbjudningar."
"Låg prioritet"
"Du kan avmarkera filter för att se dina andra chattar"
"Du har inga chattar för det här valet"
diff --git a/features/roomlist/impl/src/main/res/values-uk/translations.xml b/features/roomlist/impl/src/main/res/values-uk/translations.xml
index a7f1c26db0..8b33aee7e4 100644
--- a/features/roomlist/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-uk/translations.xml
@@ -2,6 +2,7 @@
"Ваша резервна копія чату наразі не синхронізована. Вам потрібно підтвердити ключ відновлення, щоб зберегти доступ до резервної копії чату."
"Підтвердіть ключ відновлення"
+ "Щоб ніколи не пропустити важливий дзвінок, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."
"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"
"Відхилити запрошення"
"Ви дійсно хочете відмовитися від приватного чату з %1$s?"
@@ -17,6 +18,8 @@
"Ви можете додати чат до улюблених у налаштуваннях чату.
Наразі ви можете зняти фільтри, щоб побачити інші ваші чати"
"Ви ще не маєте улюблених чатів"
+ "Запрошення"
+ "У вас немає запрошень, що очікують на розгляд."
"Низький пріоритет"
"Ви можете зняти фільтри, щоб побачити інші ваші чати"
"Ви не маєте чатів для цієї категорії"
@@ -28,8 +31,9 @@
"Вітаємо!
У вас немає непрочитаних повідомлень!"
"Усі чати"
- "Позначити як прочитане"
- "Позначити як непрочитане"
+ "Позначити прочитаним"
+ "Позначити непрочитаним"
+ "Переглянути всі кімнати"
"Схоже, Ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою."
"Підтвердьте, що це Ви"
diff --git a/features/roomlist/impl/src/main/res/values-uz/translations.xml b/features/roomlist/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..7ca2ae798a
--- /dev/null
+++ b/features/roomlist/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"
+ "Taklifni rad etish"
+ "Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"
+ "Chatni rad etish"
+ "Takliflar yo\'q"
+ "%1$s(%2$s ) sizni taklif qildi"
+ "Bu bir martalik jarayon, kutganingiz uchun rahmat."
+ "Hisobingiz sozlanmoqda."
+ "Yangi suhbat yoki xona yarating"
+ "Kimgadir xabar yuborishdan boshlang."
+ "Hozircha chatlar yo‘q."
+ "Odamlar"
+ "Suhbatlar"
+ "Siz yangi qurilmadan foydalanayotganga o‘xshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang."
+ "Siz ekanligingizni tasdiqlang"
+
diff --git a/features/roomlist/impl/src/main/res/values-zh/translations.xml b/features/roomlist/impl/src/main/res/values-zh/translations.xml
index 053de06f1a..b77275bb8d 100644
--- a/features/roomlist/impl/src/main/res/values-zh/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-zh/translations.xml
@@ -1,7 +1,9 @@
- "您的聊天备份当前不同步。您需要输入恢复密钥才能访问聊天备份。"
- "输入您的恢复密钥"
+ "聊天备份目前不同步,需要输入恢复密钥才能访问聊天备份。"
+ "输入恢复密钥"
+ "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。"
+ "提升通话体验"
"您确定要拒绝加入 %1$s 的邀请吗?"
"拒绝邀请"
"您确定要拒绝与 %1$s 开始私聊吗?"
@@ -14,11 +16,11 @@
"通过向某人发送消息来开始。"
"还没有聊天。"
"收藏夹"
- "您可以在聊天设置中将聊天添加到收藏夹中。
-现在,你可以取消选择过滤器以查看你的其他对话。"
+ "可以在聊天设置里将聊天添加到收藏夹中。
+现在,可以取消选择过滤器以查看其他对话。"
"您未收藏任何聊天"
"邀请"
- "你没有任何待处理的邀请。"
+ "没有待处理的邀请。"
"低优先级"
"您可以取消选择过滤器以查看其他对话"
"您没有关于此选项的聊天"
@@ -28,7 +30,7 @@
"您尚未进入任何聊天室"
"未读"
"恭喜!
-你没有任何未读消息!"
+没有任何未读消息!"
"全部聊天"
"标记为已读"
"标记为未读"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index 7a9a8b2744..a381dc414d 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -95,6 +95,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -219,7 +220,7 @@ class RoomListPresenterTest {
val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
- val syncService = FakeSyncService(initialState = SyncState.Running)
+ val syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
coroutineScope = scope,
@@ -250,7 +251,7 @@ class RoomListPresenterTest {
sessionVerificationService = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false)
},
- syncService = FakeSyncService(initialState = SyncState.Running)
+ syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
)
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
@@ -494,7 +495,7 @@ class RoomListPresenterTest {
}
@Test
- fun `present - when room service returns no room, then contentState is Empty `() = runTest {
+ fun `present - when room service returns no room, then contentState is Empty`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0))
diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
index 45e3a75738..416ecff1bc 100644
--- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
+++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
@@ -34,6 +34,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
@Parcelize
data object CreateNewRecoveryKey : InitialTarget
+
+ @Parcelize
+ data object ResetIdentity : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs
diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts
index 41f3ba8942..a2a2e04a5d 100644
--- a/features/securebackup/impl/build.gradle.kts
+++ b/features/securebackup/impl/build.gradle.kts
@@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.oidc.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
api(libs.statemachine)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
index f54bfaee96..d741eb11a7 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
@@ -34,6 +34,7 @@ import io.element.android.features.securebackup.impl.createkey.CreateNewRecovery
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
+import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.BackstackView
@@ -48,10 +49,11 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Assisted plugins: List,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) {
+ initialElement = when (plugins.filterIsInstance().first().initialElement) {
SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root
SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey
SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey -> NavTarget.CreateNewRecoveryKey
+ is SecureBackupEntryPoint.InitialTarget.ResetIdentity -> NavTarget.ResetIdentity
},
savedStateMap = buildContext.savedStateMap,
),
@@ -79,6 +81,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize
data object CreateNewRecoveryKey : NavTarget
+
+ @Parcelize
+ data object ResetIdentity : NavTarget
}
private val callbacks = plugins()
@@ -146,6 +151,14 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.CreateNewRecoveryKey -> {
createNode(buildContext)
}
+ is NavTarget.ResetIdentity -> {
+ val callback = object : ResetIdentityFlowNode.Callback {
+ override fun onDone() {
+ callbacks.forEach { it.onDone() }
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
}
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt
new file mode 100644
index 0000000000..16850890a7
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset
+
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class ResetIdentityFlowManager @Inject constructor(
+ private val matrixClient: MatrixClient,
+ @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
+ private val sessionVerificationService: SessionVerificationService,
+) {
+ private val resetHandleFlow: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized)
+ val currentHandleFlow: StateFlow> = resetHandleFlow
+ private var whenResetIsDoneWaitingJob: Job? = null
+
+ fun whenResetIsDone(block: () -> Unit) {
+ whenResetIsDoneWaitingJob = sessionCoroutineScope.launch {
+ sessionVerificationService.sessionVerifiedStatus.filterIsInstance().first()
+ block()
+ }
+ }
+
+ fun getResetHandle(): StateFlow> {
+ return if (resetHandleFlow.value.isLoading() || resetHandleFlow.value.isSuccess()) {
+ resetHandleFlow
+ } else {
+ resetHandleFlow.value = AsyncData.Loading()
+
+ sessionCoroutineScope.launch {
+ matrixClient.encryptionService().startIdentityReset()
+ .onSuccess { handle ->
+ resetHandleFlow.value = if (handle != null) {
+ AsyncData.Success(handle)
+ } else {
+ AsyncData.Failure(IllegalStateException("Could not get a reset identity handle"))
+ }
+ }
+ .onFailure { resetHandleFlow.value = AsyncData.Failure(it) }
+ }
+
+ resetHandleFlow
+ }
+ }
+
+ suspend fun cancel() {
+ currentHandleFlow.value.dataOrNull()?.cancel()
+ resetHandleFlow.value = AsyncData.Uninitialized
+
+ whenResetIsDoneWaitingJob?.cancel()
+ whenResetIsDoneWaitingJob = null
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt
new file mode 100644
index 0000000000..9bd8aeff03
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset
+
+import android.app.Activity
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.securebackup.impl.reset.password.ResetIdentityPasswordNode
+import io.element.android.features.securebackup.impl.reset.root.ResetIdentityRootNode
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
+import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
+import io.element.android.libraries.oidc.api.OidcEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+@ContributesNode(SessionScope::class)
+class ResetIdentityFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val resetIdentityFlowManager: ResetIdentityFlowManager,
+ private val coroutineScope: CoroutineScope,
+ private val oidcEntryPoint: OidcEntryPoint,
+) : BaseFlowNode(
+ backstack = BackStack(initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data object ResetPassword : NavTarget
+
+ @Parcelize
+ data class ResetOidc(val url: String) : NavTarget
+ }
+
+ private lateinit var activity: Activity
+ private var resetJob: Job? = null
+
+ override fun onBuilt() {
+ super.onBuilt()
+
+ lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ // If the custom tab was opened, we need to cancel the reset job
+ // when we come back to the node if the reset wasn't successful
+ coroutineScope.launch {
+ cancelResetJob()
+
+ resetIdentityFlowManager.whenResetIsDone {
+ plugins().forEach { it.onDone() }
+ }
+ }
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ // Make sure we cancel the reset job when the node is destroyed, just in case
+ coroutineScope.launch { cancelResetJob() }
+ }
+ })
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ is NavTarget.Root -> {
+ val callback = object : ResetIdentityRootNode.Callback {
+ override fun onContinue() {
+ coroutineScope.startReset()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ is NavTarget.ResetPassword -> {
+ val handle = resetIdentityFlowManager.currentHandleFlow.value.dataOrNull() as? IdentityPasswordResetHandle ?: error("No password handle found")
+ createNode(
+ buildContext,
+ listOf(ResetIdentityPasswordNode.Inputs(handle))
+ )
+ }
+ is NavTarget.ResetOidc -> {
+ oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.url)
+ }
+ }
+ }
+
+ private fun CoroutineScope.startReset() = launch {
+ resetIdentityFlowManager.getResetHandle()
+ .collectLatest { state ->
+ when (state) {
+ is AsyncData.Failure -> {
+ cancelResetJob()
+ Timber.e(state.error, "Could not load the reset identity handle.")
+ }
+ is AsyncData.Success -> {
+ when (val handle = state.data) {
+ is IdentityOidcResetHandle -> {
+ if (oidcEntryPoint.canUseCustomTab()) {
+ activity.openUrlInChromeCustomTab(null, false, handle.url)
+ } else {
+ backstack.push(NavTarget.ResetOidc(handle.url))
+ }
+ resetJob = launch { handle.resetOidc() }
+ }
+ is IdentityPasswordResetHandle -> backstack.push(NavTarget.ResetPassword)
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ private suspend fun cancelResetJob() {
+ resetJob?.cancel()
+ resetJob = null
+ resetIdentityFlowManager.cancel()
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ // Workaround to get the current activity
+ if (!this::activity.isInitialized) {
+ activity = LocalContext.current as Activity
+ }
+
+ val startResetState by resetIdentityFlowManager.currentHandleFlow.collectAsState()
+ if (startResetState.isLoading()) {
+ ProgressDialog(
+ properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
+ onDismissRequest = { coroutineScope.launch { cancelResetJob() } }
+ )
+ }
+
+ BackstackView(modifier)
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt
new file mode 100644
index 0000000000..b76dff920f
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+sealed interface ResetIdentityPasswordEvent {
+ data class Reset(val password: String) : ResetIdentityPasswordEvent
+ data object DismissError : ResetIdentityPasswordEvent
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt
new file mode 100644
index 0000000000..75d487d00e
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
+
+@ContributesNode(SessionScope::class)
+class ResetIdentityPasswordNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ coroutineDispatchers: CoroutineDispatchers,
+) : Node(buildContext, plugins = plugins) {
+ data class Inputs(val handle: IdentityPasswordResetHandle) : NodeInputs
+
+ private val presenter = ResetIdentityPasswordPresenter(
+ identityPasswordResetHandle = inputs().handle,
+ dispatchers = coroutineDispatchers
+ )
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ResetIdentityPasswordView(
+ state = state,
+ onBack = ::navigateUp
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt
new file mode 100644
index 0000000000..baa8ab2844
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class ResetIdentityPasswordPresenter(
+ private val identityPasswordResetHandle: IdentityPasswordResetHandle,
+ private val dispatchers: CoroutineDispatchers,
+) : Presenter {
+ @Composable
+ override fun present(): ResetIdentityPasswordState {
+ val coroutineScope = rememberCoroutineScope()
+
+ val resetAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
+
+ fun handleEvent(event: ResetIdentityPasswordEvent) {
+ when (event) {
+ is ResetIdentityPasswordEvent.Reset -> coroutineScope.reset(event.password, resetAction)
+ ResetIdentityPasswordEvent.DismissError -> resetAction.value = AsyncAction.Uninitialized
+ }
+ }
+
+ return ResetIdentityPasswordState(
+ resetAction = resetAction.value,
+ eventSink = ::handleEvent
+ )
+ }
+
+ private fun CoroutineScope.reset(password: String, action: MutableState>) = launch(dispatchers.io) {
+ suspend {
+ identityPasswordResetHandle.resetPassword(password).getOrThrow()
+ }.runCatchingUpdatingState(action)
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt
new file mode 100644
index 0000000000..47662b623e
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class ResetIdentityPasswordState(
+ val resetAction: AsyncAction,
+ val eventSink: (ResetIdentityPasswordEvent) -> Unit,
+)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt
new file mode 100644
index 0000000000..1a1e768426
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+
+class ResetIdentityPasswordStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aResetIdentityPasswordState(),
+ aResetIdentityPasswordState(resetAction = AsyncAction.Loading),
+ aResetIdentityPasswordState(resetAction = AsyncAction.Success(Unit)),
+ aResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("Failed"))),
+ )
+}
+
+private fun aResetIdentityPasswordState(
+ resetAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (ResetIdentityPasswordEvent) -> Unit = {},
+) = ResetIdentityPasswordState(
+ resetAction = resetAction,
+ eventSink = eventSink,
+)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt
new file mode 100644
index 0000000000..074752ddea
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.securebackup.impl.R
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.form.textFieldState
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun ResetIdentityPasswordView(
+ state: ResetIdentityPasswordState,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val passwordState = textFieldState(stateValue = "")
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
+ title = stringResource(R.string.screen_reset_encryption_password_title),
+ subTitle = stringResource(R.string.screen_reset_encryption_password_subtitle),
+ onBackClick = onBack,
+ content = {
+ Content(
+ text = passwordState.value,
+ onTextChange = { newText ->
+ if (state.resetAction.isFailure()) {
+ state.eventSink(ResetIdentityPasswordEvent.DismissError)
+ }
+ passwordState.value = newText
+ },
+ hasError = state.resetAction.isFailure(),
+ )
+ },
+ buttons = {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_reset_identity),
+ onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) },
+ destructive = true,
+ enabled = passwordState.value.isNotEmpty(),
+ )
+ }
+ )
+
+ // On success we need to wait until the screen is automatically dismissed, so we keep the progress dialog
+ if (state.resetAction.isLoading() || state.resetAction.isSuccess()) {
+ ProgressDialog()
+ }
+}
+
+@Composable
+private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) {
+ var showPassword by remember { mutableStateOf(false) }
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onTabOrEnterKeyFocusNext(LocalFocusManager.current),
+ value = text,
+ onValueChange = onTextChange,
+ label = { Text(stringResource(CommonStrings.common_password)) },
+ placeholder = { Text(stringResource(R.string.screen_reset_encryption_password_placeholder)) },
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ val image =
+ if (showPassword) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
+ val description =
+ if (showPassword) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
+
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(imageVector = image, description)
+ }
+ },
+ isError = hasError,
+ supportingText = if (hasError) {
+ { Text(stringResource(R.string.screen_reset_encryption_password_error)) }
+ } else {
+ null
+ }
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ResetIdentityPasswordViewPreview(@PreviewParameter(ResetIdentityPasswordStateProvider::class) state: ResetIdentityPasswordState) {
+ ElementPreview {
+ ResetIdentityPasswordView(
+ state = state,
+ onBack = {}
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt
new file mode 100644
index 0000000000..a1ec4cbe82
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+sealed interface ResetIdentityRootEvent {
+ data object Continue : ResetIdentityRootEvent
+ data object DismissDialog : ResetIdentityRootEvent
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt
new file mode 100644
index 0000000000..3dd9876fc0
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class ResetIdentityRootNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onContinue()
+ }
+
+ private val presenter = ResetIdentityRootPresenter()
+ private val callback: Callback = plugins.filterIsInstance().first()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ResetIdentityRootView(
+ modifier = modifier,
+ state = state,
+ onContinue = callback::onContinue,
+ onBack = ::navigateUp,
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt
new file mode 100644
index 0000000000..11c96e9ad8
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import io.element.android.libraries.architecture.Presenter
+
+class ResetIdentityRootPresenter : Presenter {
+ @Composable
+ override fun present(): ResetIdentityRootState {
+ var displayConfirmDialog by remember { mutableStateOf(false) }
+
+ fun handleEvent(event: ResetIdentityRootEvent) {
+ displayConfirmDialog = when (event) {
+ ResetIdentityRootEvent.Continue -> true
+ ResetIdentityRootEvent.DismissDialog -> false
+ }
+ }
+
+ return ResetIdentityRootState(
+ displayConfirmationDialog = displayConfirmDialog,
+ eventSink = ::handleEvent
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt
new file mode 100644
index 0000000000..de1054c97f
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+data class ResetIdentityRootState(
+ val displayConfirmationDialog: Boolean,
+ val eventSink: (ResetIdentityRootEvent) -> Unit,
+)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt
new file mode 100644
index 0000000000..8d780343fe
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class ResetIdentityRootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ ResetIdentityRootState(
+ displayConfirmationDialog = false,
+ eventSink = {}
+ ),
+ ResetIdentityRootState(
+ displayConfirmationDialog = true,
+ eventSink = {}
+ )
+ )
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt
new file mode 100644
index 0000000000..9983f06b44
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+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.securebackup.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
+import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun ResetIdentityRootView(
+ state: ResetIdentityRootState,
+ onContinue: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.AlertSolid,
+ title = stringResource(R.string.screen_encryption_reset_title),
+ subTitle = stringResource(R.string.screen_encryption_reset_subtitle),
+ isScrollable = true,
+ content = { Content() },
+ buttons = {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(id = CommonStrings.action_continue),
+ onClick = { state.eventSink(ResetIdentityRootEvent.Continue) },
+ destructive = true,
+ )
+ },
+ onBackClick = onBack,
+ )
+
+ if (state.displayConfirmationDialog) {
+ ConfirmationDialog(
+ title = stringResource(R.string.screen_reset_encryption_confirmation_alert_title),
+ content = stringResource(R.string.screen_reset_encryption_confirmation_alert_subtitle),
+ submitText = stringResource(R.string.screen_reset_encryption_confirmation_alert_action),
+ onSubmitClick = {
+ state.eventSink(ResetIdentityRootEvent.DismissDialog)
+ onContinue()
+ },
+ destructiveSubmit = true,
+ onDismiss = { state.eventSink(ResetIdentityRootEvent.DismissDialog) }
+ )
+ }
+}
+
+@Composable
+private fun Content() {
+ Column(
+ modifier = Modifier.padding(top = 8.dp, bottom = 40.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ InfoListOrganism(
+ modifier = Modifier.fillMaxWidth(),
+ items = persistentListOf(
+ InfoListItem(
+ message = stringResource(R.string.screen_encryption_reset_bullet_1),
+ iconComposable = {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = CompoundIcons.Check(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconSuccessPrimary,
+ )
+ },
+ ),
+ InfoListItem(
+ message = stringResource(R.string.screen_encryption_reset_bullet_2),
+ iconComposable = {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = CompoundIcons.Close(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ },
+ ),
+ InfoListItem(
+ message = stringResource(R.string.screen_encryption_reset_bullet_3),
+ iconComposable = {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = CompoundIcons.Close(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ },
+ ),
+ ),
+ backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
+ )
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_encryption_reset_footer),
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textActionPrimary,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ResetIdentityRootViewPreview(@PreviewParameter(ResetIdentityRootStateProvider::class) state: ResetIdentityRootState) {
+ ElementPreview {
+ ResetIdentityRootView(
+ state = state,
+ onContinue = {},
+ onBack = {},
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/res/values-be/translations.xml b/features/securebackup/impl/src/main/res/values-be/translations.xml
index b4a71d766f..db237bbe19 100644
--- a/features/securebackup/impl/src/main/res/values-be/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-be/translations.xml
@@ -16,6 +16,12 @@
"Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"
"Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"
"Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"
+ "Дадзеныя вашага ўліковага запісу, кантакты, налады і спіс чатаў будуць захаваны"
+ "Вы страціце існуючую гісторыю паведамленняў"
+ "Вам трэба будзе зноў запэўніць ўсе вашы існуючыя прылады і кантакты"
+ "Працягвайце, толькі калі вы ўпэўненыя, што страцілі ўсе астатнія прылады і ключ аднаўлення."
+ "Калі вы не ўвайшлі ў сістэму на іншых прыладах і страцілі ключ аднаўлення, вам неабходна скінуць ключы пацверджання, каб працягнуць выкарыстанне прыкладання."
+ "Скіньце ключы пацверджання, калі вы не можаце пацвердзіць яго іншым спосабам"
"Адключыць"
"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."
"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"
@@ -51,4 +57,11 @@
"Пераканайцеся, што вы можаце захаваць ключ аднаўлення ў бяспечным месцы"
"Наладка аднаўлення прайшла паспяхова"
"Наладзьце аднаўленне"
+ "Так, скінуць зараз"
+ "Гэты працэс незваротны."
+ "Вы ўпэўнены, што хочаце скінуць шыфраванне?"
+ "Адбылася невядомая памылка. Калі ласка, праверце правільнасць пароля вашага ўліковага запісу і паўтарыце спробу."
+ "Увод…"
+ "Пацвердзіце, што вы хочаце скінуць шыфраванне"
+ "Каб працягнуць, увядзіце пароль уліковага запісу"
diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml
index 18255155d2..442fa42630 100644
--- a/features/securebackup/impl/src/main/res/values-cs/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml
@@ -16,6 +16,12 @@
"Postupujte podle pokynů k vytvoření nového obnovovacího klíče"
"Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky"
"Obnovte šifrování účtu pomocí jiného zařízení"
+ "Podrobnosti o vašem účtu, kontaktech, preferencích a seznamu chatu budou zachovány"
+ "Ztratíte svou stávající historii zpráv"
+ "Budete muset znovu ověřit všechna stávající zařízení a kontakty"
+ "Obnovte svou identitu pouze v případě, že nemáte přístup k jinému přihlášenému zařízení a ztratili jste klíč pro obnovení."
+ "Pokud nejste přihlášeni k žádnému jinému zařízení a ztratili jste klíč pro obnovení, budete muset obnovit svou identitu, abyste mohli pokračovat v používání aplikace. "
+ "Obnovte svou identitu v případě, že nemůžete potvrdit jiným způsobem"
"Vypnout"
"Pokud se odhlásíte ze všech zařízení, přijdete o zašifrované zprávy."
"Opravdu chcete vypnout zálohování?"
@@ -51,4 +57,11 @@
"Ujistěte se, že můžete klíč pro obnovení uložit někde v bezpečí"
"Nastavení obnovení bylo úspěšné"
"Nastavení obnovy"
+ "Ano, resetovat nyní"
+ "Tento proces je nevratný."
+ "Opravdu chcete obnovit šifrování?"
+ "Došlo k neznámé chybě. Zkontrolujte, zda je heslo k účtu správné a zkuste to znovu."
+ "Zadat…"
+ "Potvrďte, že chcete obnovit šifrování."
+ "Pro pokračování zadejte heslo k účtu"
diff --git a/features/securebackup/impl/src/main/res/values-et/translations.xml b/features/securebackup/impl/src/main/res/values-et/translations.xml
index 00efa8cf14..cec56f5789 100644
--- a/features/securebackup/impl/src/main/res/values-et/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-et/translations.xml
@@ -16,6 +16,12 @@
"Uue taastevõtme loomiseks palun järgi juhendit"
"Salvesta oma uus taastevõti kas salasõnahalduris, krüptitud failis või mõnel muul turvalisel viisil"
"Lähtesta oma konto krüptimine mõnest muust oma seadmest"
+ "Sinu kasutajakonto andmed, kontaktid, eelistused ja vestluste loend säiluvad"
+ "Sa kaotad seniste sõnumite ajaloo"
+ "Sa pead kõik oma olemasolevad seadmed ja kontaktid uuesti verifitseerima"
+ "Lähtesta oma identiteet vaid siis, kui sul pole ligipääsu mitte ühelegi oma seadmele ja sa oled kaotanud oma taastevõtme."
+ "Kui sa soovid jätkata selle rakenduse kasutamist ja sa pole mitte üheski seadmes sisse logitud ning oled kaotanud oma taastevõtme, siis tõesti pead lähtestama oma identiteedi. "
+ "Kui sa ühtegi muud võimalust ei leia, siis lähtesta oma identiteet."
"Lülita välja"
"Kui sa logid välja kõikidest oma seadmetest, siis sa kaotad ligipääsu oma krüptitud sõnumitele."
"Kas sa oled kindel, et soovid varukoopiate tegemise välja lülitada?"
@@ -51,4 +57,11 @@
"Palun hoia taastevõtit turvaliselt, näiteks vana kooli seifis või digitaalses salasõnalaekas"
"Andmete taastamise seadistamine õnnestus"
"Seadista andmete taastamine"
+ "Jah, lähtesta nüüd"
+ "See tegevus on tagasipöördumatu."
+ "Kas sa oled kindel, et soovid oma andmete krüptimist lähtestada?"
+ "Tekkis teadmata viga. Palun kontrolli, kas sinu kasutajakonto salasõna on õige ja proovi uuesti."
+ "Sisesta…"
+ "Palun kinnita, et soovid oma andmete krüptimist lähtestada."
+ "Jätkamaks sisesta oma kasutajakonto salasõna"
diff --git a/features/securebackup/impl/src/main/res/values-fr/translations.xml b/features/securebackup/impl/src/main/res/values-fr/translations.xml
index a23f66f643..b0eb80b541 100644
--- a/features/securebackup/impl/src/main/res/values-fr/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-fr/translations.xml
@@ -16,6 +16,9 @@
"Suivez les instructions pour créer une nouvelle clé de récupération"
"Enregistrez votre nouvelle clé dans un gestionnaire de mots de passe ou dans une note chiffrée"
"Réinitialisez le chiffrement de votre compte en utilisant un autre appareil"
+ "Les détails de votre compte, vos contacts, vos préférences et votre liste de discussions seront conservés"
+ "Vous perdrez l’historique de vos messages"
+ "Vous devrez vérifier à nouveau tous vos appareils et tous vos contacts"
"Désactiver"
"Vous perdrez vos messages chiffrés si vous vous déconnectez de toutes vos sessions."
"Êtes-vous certain de vouloir désactiver la sauvegarde?"
@@ -51,4 +54,9 @@
"Assurez-vous de conserver la clé dans un endroit sûr"
"Sauvegarde mise en place avec succès"
"Configurer la sauvegarde"
+ "Oui, réinitialisez maintenant"
+ "Cette opération ne peut pas être annulée."
+ "Une erreur s’est produite. Vérifiez que le mot de passe de votre compte est correct et réessayez."
+ "Saisissez…"
+ "Saisissez le mot de passe de votre compte pour continuer"
diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml
index 68db567a89..312ad0705c 100644
--- a/features/securebackup/impl/src/main/res/values-hu/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml
@@ -16,6 +16,12 @@
"Kövesse az utasításokat egy új helyreállítási kulcs létrehozásához"
"Mentse az új helyreállítási kulcsot egy jelszókezelőbe vagy egy titkosított jegyzetbe."
"A fiók titkosításának visszaállítása egy másik eszköz használatával"
+ "A fiókadatok, a kapcsolatok, a beállítások és a csevegéslista megmarad"
+ "Elveszíti meglévő üzenetelőzményeit"
+ "Újból ellenőriznie kell az összes meglévő eszközét és csevegőpartnerét"
+ "Csak akkor állítsa vissza a személyazonosságát, ha nem fér hozzá másik bejelentkezett eszközhöz, és elvesztette a helyreállítási kulcsot."
+ "Ha nincs bejelentkezve más eszközre, és elvesztette a helyreállítási kulcsot, akkor az alkalmazás használatának folytatásához vissza kell állítania személyazonosságát. "
+ "Állítsa vissza a személyazonosságát, ha más módon nem tudja megerősíteni"
"Kikapcsolás"
"Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit."
"Biztos, hogy kikapcsolja a biztonsági mentéseket?"
@@ -51,4 +57,11 @@
"Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"
"A helyreállítás beállítása sikeres"
"Helyreállítás beállítása"
+ "Igen, visszaállítás most"
+ "Ez a folyamat visszafordíthatatlan."
+ "Biztos, hogy visszaállítja a titkosítást?"
+ "Ismeretlen hiba történt. Ellenőrizze, hogy a fiókja jelszava helyes-e, és próbálja meg újra."
+ "Adja meg…"
+ "Erősítse meg, hogy vissza szeretné állítani a titkosítást."
+ "A folytatáshoz adja meg fiókja jelszavát"
diff --git a/features/securebackup/impl/src/main/res/values-it/translations.xml b/features/securebackup/impl/src/main/res/values-it/translations.xml
index 7b2a40341a..cbeadcc2e1 100644
--- a/features/securebackup/impl/src/main/res/values-it/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-it/translations.xml
@@ -16,6 +16,12 @@
"Segui le istruzioni per creare una nuova chiave di recupero"
"Salva la tua nuova chiave di recupero in un gestore di password o in una nota cifrata."
"Reimposta la crittografia del tuo account utilizzando un altro dispositivo"
+ "I dettagli del tuo account, i contatti, le preferenze e l\'elenco delle conversazioni verranno conservati"
+ "Perderai la cronologia dei messaggi esistente"
+ "Dovrai verificare nuovamente tutti i dispositivi e i contatti esistenti"
+ "Reimposta la tua identità solo se non hai accesso a un altro dispositivo su cui hai effettuato l\'accesso e hai perso la chiave di recupero."
+ "Se non hai effettuato l\'accesso su nessun altro dispositivo e hai perso la chiave di recupero, dovrai reimpostare la tua identità per continuare a utilizzare l\'app."
+ "Reimposta la tua identità nel caso in cui non riesci a confermare in un altro modo"
"Disattiva"
"Perderai i tuoi messaggi cifrati se sei disconnesso da tutti i dispositivi."
"Vuoi davvero disattivare il backup?"
@@ -51,4 +57,10 @@
"Assicurati di conservare la chiave di recupero in un posto sicuro"
"Configurazione del recupero completata"
"Configura il recupero"
+ "Sì, reimposta ora"
+ "Questo processo è irreversibile."
+ "Sei sicuro di voler reimpostare la crittografia?"
+ "Inserisci…"
+ "Conferma di voler reimpostare la crittografia."
+ "Inserisci la password del tuo account per continuare"
diff --git a/features/securebackup/impl/src/main/res/values-nl/translations.xml b/features/securebackup/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..b4dbb23562
--- /dev/null
+++ b/features/securebackup/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,44 @@
+
+
+ "Back-up uitschakelen"
+ "Back-up inschakelen"
+ "Een back-up maken zorgt ervoor dat je je berichtgeschiedenis niet verliest. %1$s."
+ "Back-up"
+ "Herstelsleutel wijzigen"
+ "Voer herstelsleutel in"
+ "Je chatback-up is momenteel niet gesynchroniseerd."
+ "Herstelmogelijkheid instellen"
+ "Krijg toegang tot je versleutelde berichten als je al je apparaten kwijtraakt of overal uit %1$s bent uitgelogd."
+ "Uitschakelen"
+ "Je verliest je versleutelde berichten als je bent uitgelogd op alle apparaten."
+ "Weet je zeker dat je de back-up wilt uitschakelen?"
+ "Als je de back-up uitschakelt, verwijder je de back-up van je huidige versleuteling en schakel je andere beveiligingsfuncties uit. In dit geval zul je:"
+ "Geen berichtgeschiedenis hebben van versleutelde berichten op nieuwe apparaten"
+ "Toegang verliezen tot je versleutelde berichten als je overal uit %1$s bent uitgelogd."
+ "Weet je zeker dat je de back-up wilt uitschakelen?"
+ "Maak een nieuwe herstelsleutel aan als je je bestaande kwijt bent. Nadat je je herstelsleutel hebt gewijzigd, werkt je oude herstelsleutel niet meer."
+ "Genereer een nieuwe herstelsleutel"
+ "Zorg ervoor dat je je herstelsleutel op een veilige plek kunt bewaren"
+ "Herstelsleutel gewijzigd"
+ "Herstelsleutel wijzigen?"
+ "Zorg ervoor dat niemand dit scherm kan zien!"
+ "Probeer het opnieuw om toegang tot je chatback-up te bevestigen."
+ "Onjuiste herstelsleutel"
+ "Als je een beveiligingssleutel of beveiligingszin hebt, werkt dit ook."
+ "Voer in…"
+ "Herstelsleutel bevestigd"
+ "Voer je herstelsleutel in"
+ "Herstelsleutel gekopieerd"
+ "Genereren…"
+ "Herstelsleutel opslaan"
+ "Noteer je herstelsleutel op een veilige plek of bewaar deze in een wachtwoordmanager."
+ "Tik om de herstelsleutel te kopiëren"
+ "Sla je herstelsleutel op"
+ "Na deze stap kun je je nieuwe herstelsleutel niet meer inzien."
+ "Heb je je herstelsleutel opgeslagen?"
+ "Je chatback-up wordt beschermd door een herstelsleutel. Als je na de installatie een nieuwe herstelsleutel nodig hebt, kun je deze opnieuw aanmaken door \'Herstelsleutel wijzigen\' te selecteren."
+ "Genereer je herstelsleutel"
+ "Zorg ervoor dat je je herstelsleutel op een veilige plek kunt bewaren"
+ "Herstelmogelijkheid succesvol ingesteld"
+ "Herstelmogelijkheid instellen"
+
diff --git a/features/securebackup/impl/src/main/res/values-pl/translations.xml b/features/securebackup/impl/src/main/res/values-pl/translations.xml
index ca1589c5ac..749fea102a 100644
--- a/features/securebackup/impl/src/main/res/values-pl/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml
@@ -6,11 +6,24 @@
"Backup"
"Zmień klucz przywracania"
"Wprowadź klucz przywracania"
- "Backup czatu nie jest zsynchronizowany."
+ "Backup czatu jest niezsynchronizowany."
"Skonfiguruj przywracanie"
"Uzyskaj dostęp do swoich wiadomości szyfrowanych, jeśli utracisz wszystkie swoje urządzenia lub zostaniesz wylogowany z %1$s."
+ "Otwórz %1$s na urządzeniu stacjonarnym"
+ "Zaloguj się ponownie na swoje konto"
+ "Gdy pojawi się prośba o weryfikację urządzenia, wybierz %1$s"
+ "“Resetuj wszystko”"
+ "Postępuj zgodnie z instrukcjami, aby utworzyć nowy klucz przywracania"
+ "Zapisz nowy klucz przywracania w menedżerze haseł lub notatce szyfrowanej"
+ "Resetuj szyfrowanie swojego konta za pomocą drugiego urządzenia"
+ "Szczegóły konta, kontakty, preferencje i lista czatów zostaną zachowane"
+ "Utracisz istniejącą historię wiadomości"
+ "Wymagana będzie ponowna weryfikacja istniejących urządzeń i kontaktów"
+ "Zresetuj swoją tożsamość tylko wtedy, gdy nie jesteś zalogowany na żadnym urządzeniu i straciłeś swój klucz przywracania."
+ "Jeśli nie jesteś zalogowany na żadnym innym urządzeniu i straciłeś swój klucz przywracania, musisz zresetować swoją tożsamość, aby kontynuować korzystanie z aplikacji. "
+ "Zresetuj swoją tożsamość, jeśli nie możesz jej potwierdzić w inny sposób"
"Wyłącz"
- "Utracisz dostęp do wiadomości szyfrowanych, jeśli zostaniesz wylogowany ze wszystkich urządzeń."
+ "Jeśli wylogujesz się ze wszystkich urządzeń, stracisz wszystkie wiadomości szyfrowane."
"Czy na pewno chcesz wyłączyć backup?"
"Wyłączenie backupu spowoduje usunięcie kopii klucza szyfrowania i wyłączenie innych funkcji bezpieczeństwa. W takim przypadku będziesz:"
"Posiadał historii wiadomości szyfrowanych na nowych urządzeniach"
@@ -21,11 +34,14 @@
"Upewnij się, że klucz przywracania będzie trzymany w bezpiecznym miejscu"
"Zmieniono klucz przywracania"
"Zmienić klucz przywracania?"
+ "Utwórz nowy klucz przywracania"
"Upewnij się, że nikt nie widzi tego ekranu!"
"Spróbuj ponownie, aby potwierdzić dostęp do backupu czatu."
"Nieprawidłowy klucz przywracania"
"To też zadziała, jeśli posiadasz klucz lub frazę bezpieczeństwa."
+ "Klucz przywracania lub hasło"
"Wprowadź…"
+ "Zgubiłeś swój kod przywracania?"
"Potwierdzono klucz przywracania"
"Wprowadź klucz przywracania"
"Skopiowano klucz przywracania"
@@ -41,4 +57,10 @@
"Upewnij się, że klucz przywracania możesz przechowywać w bezpiecznym miejscu"
"Skonfigurowano przywracanie pomyślnie"
"Skonfiguruj przywracanie"
+ "Tak, zresetuj teraz"
+ "Tego procesu nie można odwrócić."
+ "Czy na pewno chcesz zresetować szyfrowanie?"
+ "Wprowadź…"
+ "Potwierdź, że chcesz zresetować szyfrowanie."
+ "Wprowadź hasło, aby kontynuować"
diff --git a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
index 786de3f4da..9c9ea39c24 100644
--- a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
@@ -4,7 +4,7 @@
"Ativar o backup"
"O backup garante que você não perca seu histórico de mensagens. %1$s."
"Backup"
- "Mudar chave de recuperação"
+ "Alterar chave de recuperação"
"Insira a chave de recuperação"
"Seu backup das conversas está atualmente fora de sincronia."
"Configurar a recuperação"
@@ -22,10 +22,13 @@
"Chave de recuperação alterada"
"Alterar chave de recuperação?"
"Certifique-se de que ninguém possa ver essa tela!"
+ "Chave de recuperação incorreta"
"Se você tiver uma chave de segurança ou frase de segurança, isso também funcionará."
"Inserir…"
"Chave de recuperação confirmada"
"Insira sua chave de recuperação"
+ "Chave de recuperação copiada"
+ "Gerando…"
"Salvar chave de recuperação"
"Anote sua chave de recuperação em algum lugar seguro ou salve-a em um gerenciador de senhas."
"Toque para copiar a chave de recuperação"
diff --git a/features/securebackup/impl/src/main/res/values-pt/translations.xml b/features/securebackup/impl/src/main/res/values-pt/translations.xml
index 0d8d61137c..27d41c98c2 100644
--- a/features/securebackup/impl/src/main/res/values-pt/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pt/translations.xml
@@ -16,6 +16,12 @@
"Segue as instruções para criar uma nova chave de recuperação"
"Guarda a tua nova chave de recuperação num gestor de senhas ou numa nota cifrada"
"Repor a cifragem da tua conta utilizando outro dispositivo"
+ "Os detalhes da tua conta, contactos, preferências e lista de conversas serão mantidos."
+ "Perderás o acesso ao teu histórico de mensagens existente"
+ "Necessitarás de verificar todos os teus dispositivos e contactos novamente."
+ "Repõe a tua identidade apenas se não tiveres acesso a mais nenhum dispositivo com sessão iniciada e se tiveres perdido a tua chave de recuperação."
+ "Se não tiveres sessão iniciada em nenhum outro dispositivo e perdeste o acesso à tua chave de recuperação, precisarás de repor a tua identidade para continuares a usar a aplicação. "
+ "Repõe a tua identidade caso não consigas confirmar de outra forma"
"Desligar"
"Perderás as tuas mensagens cifradas se tiveres terminado a sessão em todos os teus dispositivos."
"Tens a certeza que queres desativar a cópia de segurança?"
@@ -51,4 +57,10 @@
"Certifica-te de que podes guardar a tua chave de recuperação num local seguro"
"Recuperação configurada com sucesso"
"Configurar recuperação"
+ "Sim, repor agora"
+ "Este processo é irreversível."
+ "Tens a certeza que pretendes repor a tua cifra?"
+ "Inserir…"
+ "Confirma que pretendes realmente repor a tua cifra."
+ "Insere a tua palavra-passe para continuares"
diff --git a/features/securebackup/impl/src/main/res/values-ru/translations.xml b/features/securebackup/impl/src/main/res/values-ru/translations.xml
index 23de19d911..c02d05952d 100644
--- a/features/securebackup/impl/src/main/res/values-ru/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ru/translations.xml
@@ -26,6 +26,12 @@
" в менеджере паролей или зашифрованной заметке"
"Сбросьте шифрование вашей учетной записи с помощью другого устройства."
+ "Данные вашей учетной записи, контакты, настройки и список чатов будут сохранены"
+ "Вы потеряете существующую историю сообщений"
+ "Вам нужно будет заново подтвердить все существующие устройства и контакты."
+ "Сбрасывайте данные только в том случае, если у вас нет доступа к другому устройству, на котором выполнен вход, и вы потеряли ключ восстановления."
+ "Если вы не вошли в систему на других устройствах и потеряли ключ восстановления, вам необходимо сбросить учетные данные, чтобы продолжить использование приложения. "
+ "Сбросьте ключи подтверждения, если вы не можете подтвердить свою личность другим способом."
"Выключить"
"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."
"Вы действительно хотите отключить резервное копирование?"
@@ -94,4 +100,11 @@
"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"
"Настройка восстановления выполнена успешно"
"Настроить восстановление"
+ "Да, сбросить сейчас"
+ "Этот процесс необратим."
+ "Вы действительно хотите сбросить шифрование?"
+ "Произошла неизвестная ошибка. Проверьте правильность пароля учетной записи и повторите попытку."
+ "Ввод…"
+ "Подтвердите, что вы хотите сбросить шифрование."
+ "Введите пароль своей учетной записи, чтобы продолжить"
diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml
index 64ec3c7fac..7d15dd352a 100644
--- a/features/securebackup/impl/src/main/res/values-sk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml
@@ -16,6 +16,12 @@
"Postupujte podľa pokynov na vytvorenie nového kľúča na obnovenie"
"Uložte si nový kľúč na obnovenie do správcu hesiel alebo do zašifrovanej poznámky"
"Obnovte šifrovanie vášho účtu pomocou iného zariadenia"
+ "Údaje o vašom účte, kontakty, predvoľby a zoznam konverzácií budú zachované"
+ "Stratíte svoju existujúcu históriu správ"
+ "Budete musieť znova overiť všetky existujúce zariadenia a kontakty"
+ "Obnovte svoju totožnosť iba vtedy, ak nemáte prístup k inému prihlásenému zariadeniu a stratili ste kľúč na obnovenie."
+ "Ak nie ste prihlásení do žiadneho iného zariadenia a stratili ste kľúč na obnovenie, budete musieť znovu obnoviť svoju identitu, aby ste mohli pokračovať v používaní aplikácie. "
+ "Znovu nastavte svoju totožnosť v prípade, že ju nemôžete potvrdiť iným spôsobom"
"Vypnúť"
"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"
"Ste si istí, že chcete vypnúť zálohovanie?"
@@ -51,4 +57,11 @@
"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"
"Úspešné nastavenie obnovy"
"Nastaviť obnovenie"
+ "Áno, znovu nastaviť teraz"
+ "Tento proces je nezvratný."
+ "Naozaj chcete obnoviť svoje šifrovanie?"
+ "Nastala neznáma chyba. Skontrolujte, či je heslo vášho účtu správne a skúste to znova."
+ "Zadajte…"
+ "Potvrďte, že chcete obnoviť svoje šifrovanie."
+ "Ak chcete pokračovať, zadajte heslo účtu"
diff --git a/features/securebackup/impl/src/main/res/values-sv/translations.xml b/features/securebackup/impl/src/main/res/values-sv/translations.xml
index a1b1512031..58a8c8571b 100644
--- a/features/securebackup/impl/src/main/res/values-sv/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml
@@ -9,6 +9,19 @@
"Din chattsäkerhetskopia är för närvarande osynkroniserad."
"Ställ in återställning"
"Få tillgång till dina krypterade meddelanden om du tappar bort alla dina enheter eller blir utloggad ur %1$s överallt."
+ "Öppna %1$s på en skrivbordsenhet"
+ "Logga in på ditt konto igen"
+ "När du ombeds att verifiera din enhet, välj %1$s"
+ "”Återställ alla”"
+ "Följ anvisningarna för att skapa en ny återställningsnyckel"
+ "Spara din nya återställningsnyckel i en lösenordshanterare eller krypterad anteckning"
+ "Återställ krypteringen för ditt konto med en annan enhet"
+ "Dina kontouppgifter, kontakter, inställningar och chattlistor kommer bevaras"
+ "Du kommer att förlora din befintliga meddelandehistorik"
+ "Du måste verifiera alla dina befintliga enheter och kontakter igen"
+ "Återställ bara din identitet om du inte har tillgång till en annan inloggad enhet och du har tappat bort din återställningsnyckel."
+ "Om du inte är inloggad på någon annan enhet och du har tappat bort din återställningsnyckel måste du återställa din identitet för att fortsätta använda appen. "
+ "Återställ din identitet ifall du inte kan bekräfta på annat sätt"
"Stäng av"
"Du kommer att förlora dina krypterade meddelanden om du loggas ut från alla enheter."
"Är du säker på att du vill stänga av säkerhetskopiering?"
@@ -21,11 +34,14 @@
"Se till att du kan lagra din återställningsnyckel någonstans säkert"
"Återställningsnyckel ändrad"
"Byt återställningsnyckel?"
+ "Skapa ny återställningsnyckel"
"Se till att ingen kan se den här skärmen"
"Vänligen pröva igen för att bekräfta åtkomsten till din chattsäkerhetskopia."
"Felaktig återställningsnyckel"
"Om du har en säkerhetsnyckel eller säkerhetsfras så funkar den också."
+ "Återställningsnyckel eller lösenkod"
"Ange …"
+ "Blivit av med din återställningsnyckel?"
"Återställningsnyckel bekräftad"
"Ange din återställningsnyckel"
"Kopierade återställningsnyckel"
@@ -41,4 +57,11 @@
"Se till att du kan lagra din återställningsnyckel någonstans säkert"
"Konfiguration av återställning lyckades"
"Ställ in återställning"
+ "Ja, återställ nu"
+ "Denna process är irreversibel."
+ "Är du säker på att du vill återställa din kryptering?"
+ "Ett okänt fel inträffade. Kontrollera att ditt kontolösenord är korrekt och försök igen."
+ "Ange …"
+ "Bekräfta att du vill återställa din kryptering."
+ "Ange ditt kontolösenord för att fortsätta"
diff --git a/features/securebackup/impl/src/main/res/values-uk/translations.xml b/features/securebackup/impl/src/main/res/values-uk/translations.xml
index 2a5b029a6b..98288f1aa6 100644
--- a/features/securebackup/impl/src/main/res/values-uk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-uk/translations.xml
@@ -9,6 +9,19 @@
"Ваша резервна копія чату наразі не синхронізована."
"Налаштувати відновлення"
"Отримайте доступ до своїх зашифрованих повідомлень, якщо ви втратите всі свої пристрої або вийшли з %1$s системи."
+ "Відкрийте %1$s на комп\'ютері"
+ "Увійдіть до вашого облікового запису знову"
+ "Коли вас попросять підтвердити пристрій, виберіть %1$s"
+ "“Скинути все”"
+ "Дотримуйтесь інструкцій, щоб створити новий ключ відновлення"
+ "Збережіть новий ключ відновлення у менеджері паролів або зашифрованій нотатці"
+ "Скинути шифрування облікового запису за допомогою іншого пристрою"
+ "Дані вашого облікового запису, контакти, налаштування й чати будуть збережені"
+ "Ви втратите свою наявну історію повідомлень"
+ "Вам доведеться підтвердити всі наявні пристрої та контакти знову"
+ "Скидайте ідентичність тільки якщо ви не маєте доступу до інших пристроїв в обліковому записі та втратили свій ключ відновлення."
+ "Якщо ви не увійшли на інших пристроях та втратили свій ключ відновлення, то вам доведеться скинути свою ідентичність, щоб продовжити використовувати застосунок. "
+ "Скиньте свою ідентичність, якщо не можете підтвердити іншим способом"
"Вимкнути"
"Ви втратите зашифровані повідомлення, якщо вийдете з усіх пристроїв."
"Ви впевнені, що хочете вимкнути резервне копіювання?"
@@ -21,11 +34,14 @@
"Переконайтеся, що ви можете зберігати ключ відновлення в безпечному місці"
"Ключ відновлення змінено"
"Змінити ключ відновлення?"
- "Введіть ключ відновлення, щоб підтвердити доступ до резервної копії чату."
+ "Створити новий ключ відновлення"
+ "Впевніться, що ніхто не дивиться!"
"Будь ласка, спробуйте ще раз, щоб підтвердити доступ до резервної копії чату."
"Неправильний ключ відновлення"
- "Введіть код із 48 символів."
+ "Якщо у вас є ключ безпеки або фраза безпеки, це теж спрацює."
+ "Ключ відновлення або код допуску"
"Ввести…"
+ "Загубили ключ відновлення?"
"Ключ відновлення підтверджено"
"Підтвердіть ключ відновлення"
"Скопійовано ключ відновлення"
@@ -41,4 +57,10 @@
"Переконайтеся, що ви можете зберігати ключ відновлення в безпечному місці"
"Налаштування відновлення виконано успішно"
"Налаштувати відновлення"
+ "Так, скинути зараз"
+ "Цей процес незворотний."
+ "Ви впевнені, що хочете скинути шифрування?"
+ "Ввести…"
+ "Підтвердьте, що ви хочете скинути шифрування."
+ "Введіть пароль облікового запису, щоб продовжити"
diff --git a/features/securebackup/impl/src/main/res/values-uz/translations.xml b/features/securebackup/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..b5bea8f4b8
--- /dev/null
+++ b/features/securebackup/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "Zaxiralashni o\'chirib qo\'ying"
+ "Zaxiralashni yoqing"
+ "Zaxiralash xabarlar tarixini yo\'qotmaslikni ta\'minlaydi.%1$s."
+ "Zaxira"
+ "Qayta tiklash kalitini o\'zgartiring"
+ "Qayta tiklash kalitini kiriting"
+ "Sizning chat zaxirangiz hozirda sinxronlashtirilmagan."
+ "Qayta tiklashni sozlang"
+ "Agar barcha qurilmalaringizni yo‘qotib qo‘ysangiz yoki tizimdan chiqqan bo‘lsangiz, shifrlangan xabarlaringizga ruxsat oling%1$s hamma joyda."
+ "O\'chirish"
+ "Agar barcha qurilmalardan chiqqan boʻlsangiz, shifrlangan xabarlaringizni yoʻqotasiz."
+ "Haqiqatan ham zaxiralashni o‘chirib qo‘ymoqchimisiz?"
+ "Zaxiralashni o‘chirib qo‘ysangiz, joriy shifrlash kaliti zaxira nusxasi o‘chiriladi va boshqa xavfsizlik funksiyalari o‘chiriladi. Bunday holda siz:"
+ "Yangi qurilmalarda shifrlangan xabarlar tarixi mavjud emas"
+ "Agar tizimdan chiqqan boʻlsangiz, shifrlangan xabarlaringizga kirish huquqini yoʻqotasiz%1$s hamma joyda"
+ "Haqiqatan ham zaxiralashni o‘chirib qo‘ymoqchimisiz?"
+ "Mavjud kalitingizni yo\'qotgan bo\'lsangiz, yangi tiklash kalitini oling. Qayta tiklash kalitini almashtirganingizdan so\'ng, eski kalitingiz ishlamaydi."
+ "Yangi tiklash kalitini yarating"
+ "Qayta tiklash kalitingizni xavfsiz joyda saqlashingiz mumkinligiga ishonch hosil qiling"
+ "Qayta tiklash kaliti oʻzgartirildi"
+ "Qayta tiklash kaliti almashtirilsinmi?"
+ "Hech kim bu ekranni kora olmasligiga ishonch hosil qiling!"
+ "Agar sizda xavfsizlik kaliti yoki xavfsizlik iborasi bolsa, bu ham ishlaydi."
+ "Kirish…"
+ "Qayta tiklash kaliti tasdiqlandi"
+ "Qayta tiklash kalitingizni kiriting"
+ "Qayta tiklash kalitini saqlang"
+ "Qayta tiklash kalitingizni xavfsiz joyga yozing yoki parol menejerida saqlang."
+ "Qayta tiklash kalitidan nusxa olish uchun bosing"
+ "Zaxira kalitingizni saqlang"
+ "Ushbu qadamdan so‘ng siz yangi tiklash kalitingizga kira olmaysiz."
+ "Zaxira kalitingizni saqladingizmi?"
+ "Suhbatingiz zaxira nusxasi tiklash kaliti bilan himoyalangan. Agar sozlashdan keyin sizga yangi tiklash kaliti kerak boʻlsa, “Qayta tiklash kalitini oʻzgartirish”ni tanlash orqali qayta yaratishingiz mumkin."
+ "Qayta tiklash kalitini yarating"
+ "Qayta tiklash kalitingizni xavfsiz joyda saqlashingiz mumkinligiga ishonch hosil qiling"
+ "Qayta tiklash muvaffaqiyatli sozlandi"
+ "Qayta tiklashni sozlang"
+
diff --git a/features/securebackup/impl/src/main/res/values-zh/translations.xml b/features/securebackup/impl/src/main/res/values-zh/translations.xml
index a984d72208..f350d6ff62 100644
--- a/features/securebackup/impl/src/main/res/values-zh/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-zh/translations.xml
@@ -16,12 +16,18 @@
"按照说明创建新的恢复密钥"
"将新的恢复密钥保存在密码管理器或加密备忘录中"
"使用其他设备重置账户的加密"
+ "您的账户信息、联系人、偏好设置和聊天列表将被保留"
+ "您将丢失现有的消息历史记录"
+ "您将需要再次验证所有您的现有设备和联系人"
+ "仅当您无法访问其他已登录设备并且丢失了恢复密钥时才重置您的身份。"
+ "如果您未登录任何其他设备,并且丢失了恢复密钥,则需要重置身份才能继续使用该应用。"
+ "如果您无法通过其他方式确认,请重置您的身份"
"关闭"
"如果您登出所有设备,您的加密消息将丢失。"
"您确定要关闭备份吗?"
"关闭备份将删除您当前的加密密钥备份并关闭其他安全功能。在这种情况下,你将:"
"新设备上没有加密消息的历史记录"
- "如果您在所有设备上登出了 %1$s,那将无法访问加密的消息"
+ "如果您在所有设备上登出了 %1$s,那将无法访问加密消息"
"您确定要关闭备份吗?"
"如果您丢失了现有的恢复密钥,请获取新的恢复密钥。更改恢复密钥后,您的旧密钥将不再起作用。"
"生成新的恢复密钥"
@@ -51,4 +57,10 @@
"确保将恢复密钥存储在安全的地方"
"恢复设置成功"
"设置恢复"
+ "是的,立即重置"
+ "此过程不可逆。"
+ "您确定要重置加密吗?"
+ "输入…"
+ "确认您要重置加密。"
+ "输入您的账户密码以继续"
diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml
index e1159031a2..6c428d4039 100644
--- a/features/securebackup/impl/src/main/res/values/localazy.xml
+++ b/features/securebackup/impl/src/main/res/values/localazy.xml
@@ -16,6 +16,12 @@
"Follow the instructions to create a new recovery key"
"Save your new recovery key in a password manager or encrypted note"
"Reset the encryption for your account using another device"
+ "Your account details, contacts, preferences, and chat list will be kept"
+ "You will lose your existing message history"
+ "You will need to verify all your existing devices and contacts again"
+ "Only reset your identity if you don’t have access to another signed-in device and you’ve lost your recovery key."
+ "If you’re not signed in to any other devices and you’ve lost your recovery key, then you’ll need to reset your identity to continue using the app. "
+ "Reset your identity in case you can’t confirm another way"
"Turn off"
"You will lose your encrypted messages if you are signed out of all devices."
"Are you sure you want to turn off backup?"
@@ -51,4 +57,11 @@
"Make sure you can store your recovery key somewhere safe"
"Recovery setup successful"
"Set up recovery"
+ "Yes, reset now"
+ "This process is irreversible."
+ "Are you sure you want to reset your identity?"
+ "An unknown error happened. Please check your account password is correct and try again."
+ "Enter…"
+ "Confirm that you want to reset your identity."
+ "Enter your account password to continue"
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt
new file mode 100644
index 0000000000..eb33fe5c36
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
+import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ResetIdentityFlowManagerTest {
+ @Test
+ fun `getResetHandle - emits a reset handle`() = runTest {
+ val startResetLambda = lambdaRecorder> { Result.success(FakeIdentityPasswordResetHandle()) }
+ val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
+ val flowManager = createFlowManager(encryptionService = encryptionService)
+
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem().isLoading()).isTrue()
+ assertThat(awaitItem().isSuccess()).isTrue()
+ startResetLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `getResetHandle - om successful handle retrieval returns that same handle`() = runTest {
+ val startResetLambda = lambdaRecorder> { Result.success(FakeIdentityPasswordResetHandle()) }
+ val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
+ val flowManager = createFlowManager(encryptionService = encryptionService)
+
+ var result: AsyncData.Success? = null
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem().isLoading()).isTrue()
+ result = awaitItem() as? AsyncData.Success
+ assertThat(result).isNotNull()
+ }
+
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem()).isSameInstanceAs(result)
+ }
+ }
+
+ @Test
+ fun `getResetHandle - will fail if it receives a null reset handle`() = runTest {
+ val startResetLambda = lambdaRecorder> { Result.success(null) }
+ val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
+ val flowManager = createFlowManager(encryptionService = encryptionService)
+
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem().isLoading()).isTrue()
+ assertThat(awaitItem().isFailure()).isTrue()
+ startResetLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `getResetHandle - fails gracefully when receiving an exception from the encryption service`() = runTest {
+ val startResetLambda = lambdaRecorder> { Result.failure(IllegalStateException("Failure")) }
+ val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
+ val flowManager = createFlowManager(encryptionService = encryptionService)
+
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem().isLoading()).isTrue()
+ assertThat(awaitItem().isFailure()).isTrue()
+ startResetLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `cancel - resets the state and calls cancel on the reset handle`() = runTest {
+ val cancelLambda = lambdaRecorder { }
+ val resetHandle = FakeIdentityPasswordResetHandle(cancelLambda = cancelLambda)
+ val startResetLambda = lambdaRecorder> { Result.success(resetHandle) }
+ val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
+ val flowManager = createFlowManager(encryptionService = encryptionService)
+
+ flowManager.getResetHandle().test {
+ assertThat(awaitItem().isLoading()).isTrue()
+ assertThat(awaitItem().isSuccess()).isTrue()
+
+ flowManager.cancel()
+ cancelLambda.assertions().isCalledOnce()
+ assertThat(awaitItem().isUninitialized()).isTrue()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `whenResetIsDone - will trigger the lambda when verification status is verified`() = runTest {
+ val verificationService = FakeSessionVerificationService()
+ val flowManager = createFlowManager(sessionVerificationService = verificationService)
+ var isDone = false
+
+ flowManager.whenResetIsDone {
+ isDone = true
+ }
+
+ assertThat(isDone).isFalse()
+
+ verificationService.emitVerifiedStatus(SessionVerifiedStatus.Unknown)
+ advanceUntilIdle()
+ assertThat(isDone).isFalse()
+
+ verificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ advanceUntilIdle()
+ assertThat(isDone).isFalse()
+
+ verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
+ advanceUntilIdle()
+ assertThat(isDone).isTrue()
+ }
+
+ private fun TestScope.createFlowManager(
+ encryptionService: FakeEncryptionService = FakeEncryptionService(),
+ client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService),
+ sessionCoroutineScope: CoroutineScope = this,
+ sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
+ ) = ResetIdentityFlowManager(
+ matrixClient = client,
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionVerificationService = sessionVerificationService,
+ )
+}
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt
new file mode 100644
index 0000000000..059983df3d
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ResetIdentityPasswordPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.resetAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - Reset event succeeds`() = runTest {
+ val resetLambda = lambdaRecorder> { _ -> Result.success(Unit) }
+ val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
+ val presenter = createPresenter(identityResetHandle = resetHandle)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
+ assertThat(awaitItem().resetAction.isLoading()).isTrue()
+ assertThat(awaitItem().resetAction.isSuccess()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - Reset event can fail gracefully`() = runTest {
+ val resetLambda = lambdaRecorder> { _ -> Result.failure(IllegalStateException("Failed")) }
+ val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
+ val presenter = createPresenter(identityResetHandle = resetHandle)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
+ assertThat(awaitItem().resetAction.isLoading()).isTrue()
+ assertThat(awaitItem().resetAction.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - DismissError event resets the state`() = runTest {
+ val resetLambda = lambdaRecorder> { _ -> Result.failure(IllegalStateException("Failed")) }
+ val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
+ val presenter = createPresenter(identityResetHandle = resetHandle)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
+ assertThat(awaitItem().resetAction.isLoading()).isTrue()
+ assertThat(awaitItem().resetAction.isFailure()).isTrue()
+
+ initialState.eventSink(ResetIdentityPasswordEvent.DismissError)
+ assertThat(awaitItem().resetAction.isUninitialized()).isTrue()
+ }
+ }
+
+ private fun TestScope.createPresenter(
+ identityResetHandle: FakeIdentityPasswordResetHandle = FakeIdentityPasswordResetHandle(),
+ ) = ResetIdentityPasswordPresenter(
+ identityPasswordResetHandle = identityResetHandle,
+ dispatchers = testCoroutineDispatchers(),
+ )
+}
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt
new file mode 100644
index 0000000000..2449146399
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.password
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ResetIdentityPasswordViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `pressing the back HW button invokes the expected callback`() {
+ ensureCalledOnce {
+ rule.setResetPasswordView(
+ ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
+ onBack = it,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `clicking on the back navigation button invokes the expected callback`() {
+ ensureCalledOnce {
+ rule.setResetPasswordView(
+ ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
+ onBack = it,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking 'Reset identity' confirms the reset`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setResetPasswordView(
+ ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = eventsRecorder),
+ )
+ rule.onNodeWithText("Password").performTextInput("A password")
+
+ rule.clickOn(CommonStrings.action_reset_identity)
+
+ eventsRecorder.assertSingle(ResetIdentityPasswordEvent.Reset("A password"))
+ }
+
+ @Test
+ fun `modifying the password dismisses the error state`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setResetPasswordView(
+ ResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("A failure")), eventSink = eventsRecorder),
+ )
+ rule.onNodeWithText("Password").performTextInput("A password")
+
+ eventsRecorder.assertSingle(ResetIdentityPasswordEvent.DismissError)
+ }
+}
+
+private fun AndroidComposeTestRule.setResetPasswordView(
+ state: ResetIdentityPasswordState,
+ onBack: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ ResetIdentityPasswordView(state = state, onBack = onBack)
+ }
+}
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt
new file mode 100644
index 0000000000..feb00bf4de
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ResetIdentityRootPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = ResetIdentityRootPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.displayConfirmationDialog).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - Continue event displays the confirmation dialog`() = runTest {
+ val presenter = ResetIdentityRootPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ResetIdentityRootEvent.Continue)
+
+ assertThat(awaitItem().displayConfirmationDialog).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - DismissDialog event hides the confirmation dialog`() = runTest {
+ val presenter = ResetIdentityRootPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ResetIdentityRootEvent.Continue)
+ assertThat(awaitItem().displayConfirmationDialog).isTrue()
+
+ initialState.eventSink(ResetIdentityRootEvent.DismissDialog)
+ assertThat(awaitItem().displayConfirmationDialog).isFalse()
+ }
+ }
+}
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt
new file mode 100644
index 0000000000..da7f11ba42
--- /dev/null
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.reset.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.securebackup.impl.R
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class ResetIdentityRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `pressing the back HW button invokes the expected callback`() {
+ ensureCalledOnce {
+ rule.setResetRootView(
+ ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
+ onBack = it,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `clicking on the back navigation button invokes the expected callback`() {
+ ensureCalledOnce {
+ rule.setResetRootView(
+ ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
+ onBack = it,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ @Config(qualifiers = "h720dp")
+ fun `clicking Continue displays the confirmation dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setResetRootView(
+ ResetIdentityRootState(displayConfirmationDialog = false, eventSink = eventsRecorder),
+ )
+
+ rule.clickOn(CommonStrings.action_continue)
+
+ eventsRecorder.assertSingle(ResetIdentityRootEvent.Continue)
+ }
+
+ @Test
+ fun `clicking 'Yes, reset now' confirms the reset`() {
+ ensureCalledOnce {
+ rule.setResetRootView(
+ ResetIdentityRootState(displayConfirmationDialog = true, eventSink = {}),
+ onContinue = it,
+ )
+ rule.clickOn(R.string.screen_reset_encryption_confirmation_alert_action)
+ }
+ }
+
+ @Test
+ fun `clicking Cancel dismisses the dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setResetRootView(
+ ResetIdentityRootState(displayConfirmationDialog = true, eventSink = eventsRecorder),
+ )
+
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(ResetIdentityRootEvent.DismissDialog)
+ }
+}
+
+private fun AndroidComposeTestRule.setResetRootView(
+ state: ResetIdentityRootState,
+ onBack: () -> Unit = EnsureNeverCalled(),
+ onContinue: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ ResetIdentityRootView(state = state, onContinue = onContinue, onBack = onBack)
+ }
+}
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
index c0ebb4ee16..4fae510f00 100644
--- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt
@@ -100,7 +100,7 @@ class SharePresenter @AssistedInject constructor(
matrixClient.getRoom(roomId)?.sendMessage(
body = text,
htmlBody = null,
- mentions = emptyList(),
+ intentionalMentions = emptyList(),
)?.isSuccess.orFalse()
}
.all { it }
diff --git a/features/signedout/impl/src/main/res/values-nl/translations.xml b/features/signedout/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..90ee4efd53
--- /dev/null
+++ b/features/signedout/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Je hebt je wachtwoord gewijzigd in een andere sessie"
+ "Je hebt deze sessie verwijderd in een andere sessie"
+ "De beheerder van je server heeft je toegang ongeldig gemaakt"
+ "Je bent mogelijk uitgelogd om een van de onderstaande redenen. Meld je opnieuw aan om %s te blijven gebruiken."
+ "Je bent uitgelogd"
+
diff --git a/features/signedout/impl/src/main/res/values-uz/translations.xml b/features/signedout/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..bb386ee54f
--- /dev/null
+++ b/features/signedout/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Siz boshqa seansda parolingizni o\'zgartirdingiz"
+ "Siz seansni boshqa seansdan o\'chirib tashladingiz"
+ "Serveringiz administratori ruxsatingizni bekor qildi"
+ "Siz quyida sanab o‘tilgan sabablardan biri tufayli tizimdan chiqqan bo‘lishingiz mumkin. Foydalanishni davom ettirish uchun qayta kiring%s ."
+ "Hisobingizdan chiqdingiz"
+
diff --git a/features/userprofile/shared/src/main/res/values-nl/translations.xml b/features/userprofile/shared/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..ec08bf8650
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-nl/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Blokkeren"
+ "Geblokkeerde gebruikers kunnen je geen berichten sturen en al hun berichten worden verborgen. Je kunt ze op elk moment deblokkeren."
+ "Gebruiker blokkeren"
+ "Deblokkeren"
+ "Je zult alle berichten van hen weer kunnen zien."
+ "Gebruiker deblokkeren"
+ "Er is een fout opgetreden bij het starten van een chat"
+
diff --git a/features/userprofile/shared/src/main/res/values-uz/translations.xml b/features/userprofile/shared/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..5c7e50eaec
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-uz/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Bloklash"
+ "Bloklangan foydalanuvchilar sizga xabar yubora olmaydi va ularning barcha xabarlari yashiriladi. Ularni istalgan vaqtda blokdan chiqarishingiz mumkin."
+ "Foydalanuvchini bloklash"
+ "Blokdan chiqarish"
+ "Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi."
+ "Foydalanuvchini blokdan chiqarish"
+ "Suhbatni boshlashda xatolik yuz berdi"
+
diff --git a/features/userprofile/shared/src/main/res/values-zh/translations.xml b/features/userprofile/shared/src/main/res/values-zh/translations.xml
index d70689b326..83f884cecb 100644
--- a/features/userprofile/shared/src/main/res/values-zh/translations.xml
+++ b/features/userprofile/shared/src/main/res/values-zh/translations.xml
@@ -4,7 +4,7 @@
"被封禁的用户无法给你发消息,并且他们的消息会被隐藏。你可以随时解封。"
"封禁用户"
"解封"
- "你可以重新接收他们的消息。"
+ "可以重新接收他们的消息。"
"解封用户"
"在开始聊天时发生了错误"
diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
index 8d19ca5698..deb5cdf267 100644
--- a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
+++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
@@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onEnterRecoveryKey()
+ fun onResetKey()
fun onDone()
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
index 9ce1358683..0ed9524626 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
@@ -43,6 +43,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
state = state,
modifier = modifier,
onEnterRecoveryKey = callback::onEnterRecoveryKey,
+ onResetKey = callback::onResetKey,
onFinish = callback::onDone,
)
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
index 6b908e3ebd..446114752e 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
@@ -20,6 +20,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -53,6 +54,7 @@ import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -66,6 +68,7 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
+ onResetKey: () -> Unit,
onFinish: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -115,6 +118,7 @@ fun VerifySelfSessionView(
goBack = ::resetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinish,
+ onResetKey = onResetKey,
)
}
) {
@@ -226,6 +230,7 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
+ onResetKey: () -> Unit,
goBack: () -> Unit,
onFinish: () -> Unit,
) {
@@ -236,42 +241,72 @@ private fun BottomMenu(
when (verificationViewState) {
is FlowStep.Initial -> {
- if (verificationViewState.isLastDevice) {
- BottomMenu(
- positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
- onPositiveButtonClick = onEnterRecoveryKey,
- )
- } else {
- BottomMenu(
- positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
- onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
- negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
- onNegativeButtonClick = onEnterRecoveryKey,
+ BottomMenu {
+ if (verificationViewState.isLastDevice) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onClick = onEnterRecoveryKey,
+ )
+ } else {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_use_another_device),
+ onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
+ )
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onClick = onEnterRecoveryKey,
+ )
+ }
+ // This option should always be displayed
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
+ onClick = onResetKey,
)
}
}
is FlowStep.Canceled -> {
- BottomMenu(
- positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled),
- onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
- negativeButtonTitle = stringResource(CommonStrings.action_cancel),
- onNegativeButtonClick = goBack,
- )
+ BottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_positive_button_canceled),
+ onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_cancel),
+ onClick = goBack,
+ )
+ }
}
is FlowStep.Ready -> {
- BottomMenu(
- positiveButtonTitle = stringResource(CommonStrings.action_start),
- onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
- negativeButtonTitle = stringResource(CommonStrings.action_cancel),
- onNegativeButtonClick = goBack,
- )
+ BottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_start),
+ onClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_cancel),
+ onClick = goBack,
+ )
+ }
}
is FlowStep.AwaitingOtherDeviceResponse -> {
- BottomMenu(
- positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device),
- onPositiveButtonClick = {},
- isLoading = true,
- )
+ BottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_waiting_on_other_device),
+ onClick = {},
+ showProgress = true,
+ )
+ // Placeholder so the 1st button keeps its vertical position
+ Spacer(modifier = Modifier.height(40.dp))
+ }
}
is FlowStep.Verifying -> {
val positiveButtonTitle = if (isVerifying) {
@@ -279,23 +314,34 @@ private fun BottomMenu(
} else {
stringResource(R.string.screen_session_verification_they_match)
}
- BottomMenu(
- positiveButtonTitle = positiveButtonTitle,
- onPositiveButtonClick = {
- if (!isVerifying) {
- eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
- }
- },
- negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match),
- onNegativeButtonClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
- isLoading = isVerifying,
- )
+ BottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = positiveButtonTitle,
+ showProgress = isVerifying,
+ onClick = {
+ if (!isVerifying) {
+ eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
+ }
+ },
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_they_dont_match),
+ onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
+ )
+ }
}
is FlowStep.Completed -> {
- BottomMenu(
- positiveButtonTitle = stringResource(CommonStrings.action_continue),
- onPositiveButtonClick = onFinish,
- )
+ BottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_continue),
+ onClick = onFinish,
+ )
+ // Placeholder so the 1st button keeps its vertical position
+ Spacer(modifier = Modifier.height(48.dp))
+ }
}
is FlowStep.Skipped -> return
}
@@ -303,35 +349,13 @@ private fun BottomMenu(
@Composable
private fun BottomMenu(
- positiveButtonTitle: String?,
- onPositiveButtonClick: () -> Unit,
modifier: Modifier = Modifier,
- negativeButtonTitle: String? = null,
- negativeButtonEnabled: Boolean = negativeButtonTitle != null,
- onNegativeButtonClick: () -> Unit = {},
- isLoading: Boolean = false,
+ buttons: @Composable ColumnScope.() -> Unit,
) {
ButtonColumnMolecule(
modifier = modifier.padding(bottom = 16.dp)
) {
- if (positiveButtonTitle != null) {
- Button(
- text = positiveButtonTitle,
- showProgress = isLoading,
- modifier = Modifier.fillMaxWidth(),
- onClick = onPositiveButtonClick,
- )
- }
- if (negativeButtonTitle != null) {
- TextButton(
- text = negativeButtonTitle,
- modifier = Modifier.fillMaxWidth(),
- onClick = onNegativeButtonClick,
- enabled = negativeButtonEnabled,
- )
- } else {
- Spacer(modifier = Modifier.height(48.dp))
- }
+ buttons()
}
}
@@ -341,6 +365,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = {},
+ onResetKey = {},
onFinish = {},
)
}
diff --git a/features/verifysession/impl/src/main/res/values-be/translations.xml b/features/verifysession/impl/src/main/res/values-be/translations.xml
index 8b2c5e920d..cdb8cedfe0 100644
--- a/features/verifysession/impl/src/main/res/values-be/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-be/translations.xml
@@ -1,8 +1,11 @@
+ "Не можаце пацвердзіць?"
"Стварыць новы ключ аднаўлення"
"Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."
"Пацвердзіце, што гэта вы"
+ "Выкарыстоўвайце іншую прыладу"
+ "Выкарыстоўваць ключ аднаўлення"
"Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."
"Прылада праверана"
"Выкарыстоўвайце іншую прыладу"
diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml
index fadcba869c..7d012a09da 100644
--- a/features/verifysession/impl/src/main/res/values-cs/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml
@@ -1,8 +1,11 @@
+ "Nemůžete potvrdit?"
"Vytvoření nového klíče pro obnovení"
"Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."
"Potvrďte, že jste to vy"
+ "Použít jiné zařízení"
+ "Použít klíč pro obnovení"
"Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."
"Zařízení ověřeno"
"Použít jiné zařízení"
diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml
index baad607403..a66479b3c1 100644
--- a/features/verifysession/impl/src/main/res/values-de/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-de/translations.xml
@@ -3,6 +3,7 @@
"Erstelle einen neuen Wiederherstellungsschlüssel"
"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."
"Bestätige, dass du es bist"
+ "Ein anderes Gerät verwenden"
"Du kannst nun verschlüsselte Nachrichten lesen oder versenden."
"Gerät verifiziert"
"Ein anderes Gerät verwenden"
diff --git a/features/verifysession/impl/src/main/res/values-el/translations.xml b/features/verifysession/impl/src/main/res/values-el/translations.xml
index 5b0402cf50..93838717b7 100644
--- a/features/verifysession/impl/src/main/res/values-el/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-el/translations.xml
@@ -3,6 +3,7 @@
"Δημιουργία νέου κλειδιού ανάκτησης"
"Επαλήθευσε αυτήν τη συσκευή για να ρυθμίσεις την ασφαλή επικοινωνία."
"Επιβεβαίωσε ότι είσαι εσύ"
+ "Χρήση άλλης συσκευής"
"Τώρα μπορείς να διαβάζεις ή να στέλνεις μηνύματα με ασφάλεια και επίσης μπορεί να εμπιστευτεί αυτήν τη συσκευή οποιοσδήποτε με τον οποίο συνομιλείς."
"Επαληθευμένη συσκευή"
"Χρήση άλλης συσκευής"
diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml
index b165818726..75773bd081 100644
--- a/features/verifysession/impl/src/main/res/values-es/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-es/translations.xml
@@ -2,8 +2,10 @@
"Verifica este dispositivo para configurar la mensajería segura."
"Confirma que eres tú"
+ "Usar otro dispositivo"
"Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo."
"Dispositivo verificado"
+ "Usar otro dispositivo"
"Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó."
"Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión."
"Comparar emojis"
diff --git a/features/verifysession/impl/src/main/res/values-et/translations.xml b/features/verifysession/impl/src/main/res/values-et/translations.xml
index 219c7653e1..a9baa5bd9e 100644
--- a/features/verifysession/impl/src/main/res/values-et/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-et/translations.xml
@@ -1,11 +1,14 @@
+ "Kas kinnitamine pole võimalik?"
"Loo uus taastevõti"
"Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade."
"Kinnita, et see oled sina"
+ "Kasuta teist seadet"
+ "Kasuta taastevõtit"
"Nüüd saad saata või lugeda sõnumeid turvaliselt ning kõik sinu vestluspartnerid võivad usaldada seda seadet."
"Seade on verifitseeritud"
- "Kasuta mõnda muud seadet"
+ "Kasuta teist seadet"
"Ootame teise seadme järgi…"
"Olukord pole päris õige. Päring kas aegus või teine osapool keeldus päringule vastamast."
"Kinnita, et kõik järgnevalt kuvatud emojid on täpselt samad, mida sa näed oma teises sessioonis."
diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml
index 6f7aa9982b..b1f3ad365b 100644
--- a/features/verifysession/impl/src/main/res/values-fr/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml
@@ -1,8 +1,11 @@
+ "Confirmation impossible ?"
"Créer une nouvelle clé de récupération"
"Vérifier cette session pour configurer votre messagerie sécurisée."
"Confirmez votre identité"
+ "Utiliser une autre session"
+ "Utiliser la clé de récupération"
"Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."
"Session vérifiée"
"Utiliser une autre session"
diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml
index c31adb9736..3de5d84c48 100644
--- a/features/verifysession/impl/src/main/res/values-hu/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml
@@ -1,8 +1,11 @@
+ "Nem tudja megerősíteni?"
"Új helyreállítási kulcs létrehozása"
"A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."
"Erősítse meg, hogy Ön az"
+ "Másik eszköz használata"
+ "Helyreállítási kulcs használata"
"Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."
"Eszköz ellenőrizve"
"Másik eszköz használata"
diff --git a/features/verifysession/impl/src/main/res/values-in/translations.xml b/features/verifysession/impl/src/main/res/values-in/translations.xml
index 3a3bec959e..50dc87d765 100644
--- a/features/verifysession/impl/src/main/res/values-in/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-in/translations.xml
@@ -3,6 +3,7 @@
"Buat kunci pemulihan baru"
"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."
"Konfirmasi bahwa ini Anda"
+ "Gunakan perangkat lain"
"Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."
"Perangkat terverifikasi"
"Gunakan perangkat lain"
diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml
index 5e22712283..d36733d08b 100644
--- a/features/verifysession/impl/src/main/res/values-it/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-it/translations.xml
@@ -1,8 +1,11 @@
+ "Non puoi confermare?"
"Crea una nuova chiave di recupero"
"Verifica questo dispositivo per segnare i tuoi messaggi come sicuri."
"Conferma la tua identità"
+ "Usa un altro dispositivo"
+ "Usa la chiave di recupero"
"Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo."
"Dispositivo verificato"
"Usa un altro dispositivo"
diff --git a/features/verifysession/impl/src/main/res/values-nl/translations.xml b/features/verifysession/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..b04e03307d
--- /dev/null
+++ b/features/verifysession/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,21 @@
+
+
+ "Er lijkt iets niet goed te gaan. Of er is een time-out opgetreden of het verzoek is geweigerd."
+ "Bevestig dat de emoji\'s hieronder overeenkomen met de emoji\'s in je andere sessie."
+ "Vergelijk emoji\'s"
+ "Bevestig dat de onderstaande cijfers overeenkomen met de cijfers die worden weergegeven in je andere sessie."
+ "Vergelijk getallen"
+ "Je nieuwe sessie is nu geverifieerd. Het heeft toegang tot je versleutelde berichten en andere gebruikers zullen het als vertrouwd beschouwen."
+ "Voer recovery key in"
+ "Bewijs dat jij het bent om toegang te krijgen tot je versleutelde berichtgeschiedenis."
+ "Open een bestaande sessie"
+ "Verificatie opnieuw proberen"
+ "Ik ben er klaar voor"
+ "Wachten om te vergelijken"
+ "Vergelijk een unieke combinatie van emoji\'s."
+ "Vergelijk de unieke emoji\'s, ze dienen in dezelfde volgorde te worden weergegeven."
+ "Ze komen niet overeen"
+ "Ze komen overeen"
+ "Accepteer het verzoek tot verificatie in je andere sessie om door te gaan."
+ "Wachten om verzoek te accepteren"
+
diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml
index 6f39abf905..506889fbae 100644
--- a/features/verifysession/impl/src/main/res/values-pl/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml
@@ -1,11 +1,22 @@
+ "Nie możesz potwierdzić?"
+ "Utwórz nowy klucz przywracania"
+ "Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości."
+ "Potwierdź, że to Ty"
+ "Użyj innego urządzenia"
+ "Użyj klucza przywracania"
+ "Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu."
+ "Urządzenie zweryfikowane"
+ "Użyj innego urządzenia"
+ "Oczekiwanie na inne urządzenie…"
"Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone."
- "Upewnij się, że poniższe emotikony pasują do tych wyświetlanych na innej sesji."
+ "Upewnij się, że emoji poniżej pasują do tych pokazanych na innej sesji."
"Porównaj emotki"
"Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji."
"Porównaj liczby"
"Twoja nowa sesja jest teraz zweryfikowana. Ma ona dostęp do Twoich zaszyfrowanych wiadomości, a inni użytkownicy będą widzieć ją jako zaufaną."
+ "Wprowadź klucz przywracania"
"Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."
"Otwórz istniejącą sesję"
"Ponów weryfikację"
diff --git a/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml
index 9d4658be82..31da2e43d1 100644
--- a/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,14 +1,21 @@
+ "Usar outro dispositivo"
+ "Dispositivo verificado"
+ "Usar outro dispositivo"
"Algo não parece certo. Ou a solicitação atingiu o tempo limite ou a solicitação foi negada."
"Confirme se os emojis abaixo correspondem aos mostrados em sua outra sessão."
"Compare os emojis"
+ "Confirme se os números abaixo correspondem aos mostrados em sua outra sessão."
+ "Comparar números"
"Sua nova sessão está agora verificada. Ela tem acesso às suas mensagens criptografadas e outros usuários a verão como confiável."
+ "Insira a chave de recuperação"
"Prove que é você para acessar seu histórico de mensagens criptografadas."
"Abrir uma sessão existente"
"Repetir verificação"
"Estou pronto"
"Esperando para combinar"
+ "Compare um conjunto único de emojis."
"Compare os emojis únicos, garantindo que apareçam na mesma ordem."
"Eles não combinam"
"Eles combinam"
diff --git a/features/verifysession/impl/src/main/res/values-pt/translations.xml b/features/verifysession/impl/src/main/res/values-pt/translations.xml
index b563196913..a4d45ec9ef 100644
--- a/features/verifysession/impl/src/main/res/values-pt/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pt/translations.xml
@@ -1,8 +1,11 @@
+ "Não é possível confirmar?"
"Criar uma nova chave de recuperação"
"Verifica este dispositivo para configurar o envio seguro de mensagens."
"Confirma que és tu"
+ "Utilizar outro dispositivo"
+ "Utilizar chave de recuperação"
"Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo."
"Dispositivo verificado"
"Utilizar outro dispositivo"
diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml
index 91e837ddec..69bb6a20f5 100644
--- a/features/verifysession/impl/src/main/res/values-ro/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml
@@ -3,6 +3,7 @@
"Creați o nouă cheie de recuperare"
"Verificați acest dispozitiv pentru a configura mesagerie securizată."
"Confirmați că sunteți dumneavoastră"
+ "Utilizați un alt dispozitiv"
"Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv."
"Dispozitiv verificat"
"Utilizați un alt dispozitiv"
diff --git a/features/verifysession/impl/src/main/res/values-ru/translations.xml b/features/verifysession/impl/src/main/res/values-ru/translations.xml
index c89077d172..fc685356d7 100644
--- a/features/verifysession/impl/src/main/res/values-ru/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ru/translations.xml
@@ -1,11 +1,14 @@
+ "Не можете подтвердить?"
"Создайте новый "
"ключ восстановления"
"Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."
"Подтвердите, что это вы"
+ "Используйте другое устройство"
+ "Используйте recovery key"
"Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."
"Устройство проверено"
"Используйте другое устройство"
diff --git a/features/verifysession/impl/src/main/res/values-sk/translations.xml b/features/verifysession/impl/src/main/res/values-sk/translations.xml
index b3089b7b30..a2ec2cb4b2 100644
--- a/features/verifysession/impl/src/main/res/values-sk/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-sk/translations.xml
@@ -1,11 +1,14 @@
+ "Nemôžete potvrdiť?"
"Vytvoriť nový kľúč na obnovenie"
"Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."
"Potvrďte, že ste to vy"
+ "Použite iné zariadenie"
+ "Použiť kľúč na obnovenie"
"Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."
"Zariadenie overené"
- "Použiť iné zariadenie"
+ "Použite iné zariadenie"
"Čaká sa na druhom zariadení…"
"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."
"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."
diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml
index 392754174b..524bc02b1e 100644
--- a/features/verifysession/impl/src/main/res/values-sv/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml
@@ -1,8 +1,11 @@
+ "Kan du inte bekräfta?"
"Skapa en ny återställningsnyckel"
"Verifiera den här enheten för att konfigurera säkra meddelanden."
"Bekräfta att det är du"
+ "Använd en annan enhet"
+ "Använd återställningsnyckel"
"Nu kan du läsa eller skicka meddelanden säkert, och alla du chattar med kan också lita på den här enheten."
"Enhet verifierad"
"Använd en annan enhet"
diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml
index 9be10a2bb0..b76304f537 100644
--- a/features/verifysession/impl/src/main/res/values-uk/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml
@@ -1,9 +1,12 @@
+ "Не можете підтвердити?"
+ "Створити новий ключ відновлення"
"Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."
"Підтвердіть, що це ви"
"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."
"Пристрій перевірено"
+ "Чекає на інше пристрій…"
"Щось не так. Або час очікування запиту минув, або в запиті було відмовлено."
"Переконайтеся, що емодзі нижче збігаються з тими, що відображаються під час іншого сеансу."
"Порівняти емодзі"
diff --git a/features/verifysession/impl/src/main/res/values-uz/translations.xml b/features/verifysession/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..87b780aa1a
--- /dev/null
+++ b/features/verifysession/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Nimadir noto‘g‘ri ko‘rinadi. Yoki so‘rov muddati tugadi yoki so‘rov rad etildi."
+ "Quyidagi kulgichlar boshqa seansda ko‘rsatilganlarga mos kelishini tasdiqlang."
+ "Emojilarni solishtiring"
+ "Yangi seansingiz tasdiqlandi. U sizning shifrlangan xabarlaringizga kirish huquqiga ega va boshqa foydalanuvchilar uni ishonchli deb bilishadi."
+ "Shifrlangan xabarlar tarixiga kirish uchun shaxsingizni tasdiqlang."
+ "Mavjud seansni oching"
+ "Tasdiqlashni qaytadan urining"
+ "Men tayyorman"
+ "Mos kelishi kutilmoqda"
+ "Noyob emojilarni solishtiring, ular bir xil tartibda paydo bo\'lishiga ishonch hosil qiling."
+ "Ular mos kelmaydi"
+ "Ular mos keladi"
+ "Davom etish uchun boshqa seansda tekshirish jarayonini boshlash soʻrovini qabul qiling."
+ "Soʻrovni qabul qilish kutilmoqda"
+
diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
index f5a26eaf33..512e806c47 100644
--- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
@@ -3,6 +3,7 @@
"建立新的復原金鑰"
"驗證這部裝置以設定安全通訊。"
"確認這是你本人"
+ "使用另一部裝置"
"您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。"
"裝置已驗證"
"使用另一部裝置"
diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml
index 15cf93695f..765da43569 100644
--- a/features/verifysession/impl/src/main/res/values-zh/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml
@@ -1,8 +1,11 @@
+ "无法确认?"
"创建新的恢复密钥"
"验证此设备以开始安全地收发消息。"
"确认这是你"
+ "使用其他设备"
+ "使用恢复密钥"
"现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。"
"设备已验证"
"使用其他设备"
@@ -12,7 +15,7 @@
"比较表情符号"
"确认以下数字与其他会话中显示的一致。"
"比较数字"
- "你的新设备已经成功验证。现在新设备可以访问加密信息,别的用户也会信任这个设备。"
+ "新设备已经成功验证。现在新设备可以访问加密信息,其他用户也会信任这个设备。"
"输入恢复密钥"
"证明自己的身份以访问加密历史消息。"
"打开已有会话"
diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml
index 41cd2f8dbc..45eda758a2 100644
--- a/features/verifysession/impl/src/main/res/values/localazy.xml
+++ b/features/verifysession/impl/src/main/res/values/localazy.xml
@@ -1,8 +1,11 @@
+ "Can\'t confirm?"
"Create a new recovery key"
"Verify this device to set up secure messaging."
"Confirm that it\'s you"
+ "Use another device"
+ "Use recovery key"
"Now you can read or send messages securely, and anyone you chat with can also trust this device."
"Device verified"
"Use another device"
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
index f19f3e1f29..7e5bf928e2 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
@@ -217,12 +217,14 @@ class VerifySelfSessionViewTest {
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
+ onResetKey: () -> Unit = EnsureNeverCalled(),
) {
setContent {
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished,
+ onResetKey = onResetKey,
)
}
}
diff --git a/gradle.properties b/gradle.properties
index b1f6468108..2d4c31a3aa 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -59,6 +59,3 @@ android.enableBuildConfigAsBytecode=true
# By default, the plugin applies itself to all subprojects, but we don't want that as it would cause issues with builds using local AARs
dependency.analysis.autoapply=false
-
-# Disable new R8 shrinking for local dependencies as it causes issues with release builds
-android.disableMinifyLocalDependenciesForLibraries=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a1fede3e54..81f93b1c0e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,9 +3,9 @@
[versions]
# Project
-android_gradle_plugin = "8.4.1"
-kotlin = "1.9.24"
-ksp = "1.9.24-1.0.20"
+android_gradle_plugin = "8.5.2"
+kotlin = "1.9.25"
+ksp = "1.9.25-1.0.20"
firebaseAppDistribution = "5.0.0"
# AndroidX
@@ -18,14 +18,14 @@ core = "1.13.1"
datastore = "1.0.0"
constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1"
-lifecycle = "2.7.0"
-activity = "1.9.0"
-media3 = "1.3.1"
+lifecycle = "2.8.4"
+activity = "1.9.1"
+media3 = "1.4.1"
camera = "1.3.4"
# Compose
-compose_bom = "2024.06.00"
-composecompiler = "1.5.14"
+compose_bom = "2024.08.00"
+composecompiler = "1.5.15"
# Coroutines
coroutines = "1.8.1"
@@ -39,16 +39,16 @@ test_core = "1.6.1"
#other
coil = "2.7.0"
datetime = "0.6.0"
-dependencyAnalysis = "1.32.0"
+dependencyAnalysis = "2.0.0"
serialization_json = "1.6.3"
showkase = "1.0.3"
appyx = "1.4.0"
sqldelight = "2.0.2"
-wysiwyg = "2.37.7"
-telephoto = "0.12.1"
+wysiwyg = "2.37.8"
+telephoto = "0.13.0"
# DI
-dagger = "2.51.1"
+dagger = "2.52"
anvil = "2.4.9"
# Auto service
@@ -56,18 +56,18 @@ autoservice = "1.1.1"
# quality
androidx-test-ext-junit = "1.2.1"
-kover = "0.8.0"
+kover = "0.8.3"
[libraries]
# Project
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
# https://developer.android.com/studio/write/java8-support#library-desugaring-versions
-android_desugar = "com.android.tools:desugar_jdk_libs:2.0.4"
+android_desugar = "com.android.tools:desugar_jdk_libs:2.1.0"
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:33.1.2"
+google_firebase_bom = "com.google.firebase:firebase-bom:33.2.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" }
oss_licenses_plugin = "com.google.android.gms:oss-licenses-plugin:0.10.6"
@@ -75,7 +75,7 @@ oss_licenses_plugin = "com.google.android.gms:oss-licenses-plugin:0.10.6"
# AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
-androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.8.0"
+androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.8.2"
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
@@ -138,7 +138,7 @@ test_core = { module = "androidx.test:core", version.ref = "test_core" }
test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
-test_runner = "androidx.test:runner:1.6.1"
+test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.12"
test_konsist = "com.lemonappdev:konsist:0.15.1"
test_turbine = "app.cash.turbine:turbine:1.1.0"
@@ -163,7 +163,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.34"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.40"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -172,12 +172,12 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0"
-otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
+otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.0"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
-maplibre = "org.maplibre.gl:android-sdk:11.0.1"
+maplibre = "org.maplibre.gl:android-sdk:11.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0"
opusencoder = "io.element.android:opusencoder:1.1.0"
@@ -186,8 +186,8 @@ zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
play_services_oss_licenses = "com.google.android.gms:play-services-oss-licenses:17.1.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.4.2"
-sentry = "io.sentry:sentry-android:7.12.0"
+posthog = "com.posthog:posthog-android:3.5.1"
+sentry = "io.sentry:sentry-android:7.14.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1"
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index e6441136f3..a4b76b9530 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 68e8816d71..2b189974c2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+distributionSha256Sum=5b9c5eb3f9fc2c94abaea57d90bd78747ca117ddbbf96c859d3741181a12bf2a
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index b740cf1339..f5feea6d6b 100755
--- a/gradlew
+++ b/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/gradlew.bat b/gradlew.bat
index 7101f8e467..9b42019c79 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
index ec0d9662c7..425133b797 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt
@@ -47,12 +47,19 @@ fun Activity.openUrlInChromeCustomTab(
true -> CustomTabsIntent.COLOR_SCHEME_DARK
}
)
+ .setShareIdentityEnabled(false)
// Note: setting close button icon does not work
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
.apply { session?.let { setSession(it) } }
.build()
+ .apply {
+ // Disable download button
+ intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true)
+ // Disable bookmark button
+ intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_START_BUTTON", true)
+ }
.launchUrl(this, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {
// TODO context.toast(R.string.error_no_external_application_found)
diff --git a/libraries/androidutils/src/main/res/values-nl/translations.xml b/libraries/androidutils/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..318e57815d
--- /dev/null
+++ b/libraries/androidutils/src/main/res/values-nl/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Er is geen compatibele app gevonden om deze actie uit te voeren."
+
diff --git a/libraries/androidutils/src/main/res/values-uz/translations.xml b/libraries/androidutils/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..a41abbde52
--- /dev/null
+++ b/libraries/androidutils/src/main/res/values-uz/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Bu amalni bajarish uchun mos ilova topilmadi."
+
diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts
index e737220961..81abc3127f 100644
--- a/libraries/designsystem/build.gradle.kts
+++ b/libraries/designsystem/build.gradle.kts
@@ -29,7 +29,6 @@ android {
buildTypes {
getByName("release") {
- isMinifyEnabled = true
consumerProguardFiles("consumer-rules.pro")
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt
new file mode 100644
index 0000000000..f22a573e77
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components.dialogs
+
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.ElementThemedPreview
+import io.element.android.libraries.designsystem.preview.PreviewGroup
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.DialogPreview
+import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AlertDialog(
+ content: String,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ submitText: String = AlertDialogDefaults.submitText,
+) {
+ BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
+ AlertDialogContent(
+ title = title,
+ content = content,
+ submitText = submitText,
+ onSubmitClick = onDismiss,
+ )
+ }
+}
+
+@Composable
+private fun AlertDialogContent(
+ content: String,
+ onSubmitClick: () -> Unit,
+ title: String? = AlertDialogDefaults.title,
+ submitText: String = AlertDialogDefaults.submitText,
+) {
+ SimpleAlertDialogContent(
+ title = title,
+ content = content,
+ submitText = submitText,
+ onSubmitClick = onSubmitClick,
+ )
+}
+
+object AlertDialogDefaults {
+ val title: String? @Composable get() = null
+ val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok)
+}
+
+@Preview(group = PreviewGroup.Dialogs)
+@Composable
+internal fun AlertDialogContentPreview() {
+ ElementThemedPreview(showBackground = false) {
+ DialogPreview {
+ AlertDialogContent(
+ content = "Content",
+ onSubmitClick = {},
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun AlertDialogPreview() = ElementPreview {
+ AlertDialog(
+ content = "Content",
+ onDismiss = {},
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index 5fc6fd2a23..4603541055 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -179,6 +179,14 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
+@OptIn(CoreColorToken::class)
+val SemanticColors.pinnedMessageBannerIndicator
+ get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
+
+@OptIn(CoreColorToken::class)
+val SemanticColors.pinnedMessageBannerBorder
+ get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
+
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt
new file mode 100644
index 0000000000..7e1eb53461
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.utils
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+
+/**
+ * Returns whether the lazy list is currently scrolling up.
+ */
+@Composable
+fun LazyListState.isScrollingUp(): Boolean {
+ var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
+ var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
+ return remember(this) {
+ derivedStateOf {
+ if (previousIndex != firstVisibleItemIndex) {
+ previousIndex > firstVisibleItemIndex
+ } else {
+ previousScrollOffset >= firstVisibleItemScrollOffset
+ }.also {
+ previousIndex = firstVisibleItemIndex
+ previousScrollOffset = firstVisibleItemScrollOffset
+ }
+ }
+ }.value
+}
diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts
index 40db7f7fde..f64e22875e 100644
--- a/libraries/encrypted-db/build.gradle.kts
+++ b/libraries/encrypted-db/build.gradle.kts
@@ -22,7 +22,7 @@ android {
buildTypes {
release {
- isMinifyEnabled = true
+ isMinifyEnabled = false
consumerProguardFiles("consumer-proguard-rules.pro")
}
}
diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt
new file mode 100644
index 0000000000..9db3a19bd7
--- /dev/null
+++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.eventformatter.api
+
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+
+interface PinnedMessagesBannerFormatter {
+ fun format(event: EventTimelineItem): CharSequence
+}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
new file mode 100644
index 0000000000..05403e5355
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.eventformatter.impl
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.text.AnnotatedString
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
+import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
+import io.element.android.libraries.matrix.ui.messages.toPlainText
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.services.toolbox.api.strings.StringProvider
+import javax.inject.Inject
+
+@ContributesBinding(SessionScope::class)
+class DefaultPinnedMessagesBannerFormatter @Inject constructor(
+ private val sp: StringProvider,
+ private val permalinkParser: PermalinkParser,
+) : PinnedMessagesBannerFormatter {
+ override fun format(event: EventTimelineItem): CharSequence {
+ return when (val content = event.content) {
+ is MessageContent -> processMessageContents(event, content)
+ is StickerContent -> {
+ content.body.prefixWith(CommonStrings.common_sticker)
+ }
+ is UnableToDecryptContent -> {
+ sp.getString(CommonStrings.common_waiting_for_decryption_key)
+ }
+ is PollContent -> {
+ content.question.prefixWith(CommonStrings.a11y_poll)
+ }
+ RedactedContent -> {
+ sp.getString(CommonStrings.common_message_removed)
+ }
+ else -> {
+ sp.getString(CommonStrings.common_unsupported_event)
+ }
+ }
+ }
+
+ private fun processMessageContents(
+ event: EventTimelineItem,
+ messageContent: MessageContent,
+ ): CharSequence {
+ return when (val messageType: MessageType = messageContent.type) {
+ is EmoteMessageType -> {
+ val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
+ "* $senderDisambiguatedDisplayName ${messageType.body}"
+ }
+ is TextMessageType -> {
+ messageType.toPlainText(permalinkParser)
+ }
+ is VideoMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_video)
+ }
+ is ImageMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_image)
+ }
+ is StickerMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_sticker)
+ }
+ is LocationMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_shared_location)
+ }
+ is FileMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_file)
+ }
+ is AudioMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_audio)
+ }
+ is VoiceMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_voice_message)
+ }
+ is OtherMessageType -> {
+ messageType.body
+ }
+ is NoticeMessageType -> {
+ messageType.body
+ }
+ }
+ }
+
+ private fun CharSequence.prefixWith(@StringRes res: Int): AnnotatedString {
+ val prefix = sp.getString(res)
+ return prefixWith(prefix)
+ }
+}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index 13655c48f2..ac125b48e6 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -16,11 +16,6 @@
package io.element.android.libraries.eventformatter.impl
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.withStyle
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
@@ -79,7 +74,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
- prefix(message, senderDisambiguatedDisplayName)
+ message.prefixWith(senderDisambiguatedDisplayName)
} else {
message
}
@@ -90,7 +85,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
- prefix(message, senderDisambiguatedDisplayName)
+ message.prefixWith(senderDisambiguatedDisplayName)
} else {
message
}
@@ -113,7 +108,6 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_call_invite)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
- else -> null
}?.take(MAX_SAFE_LENGTH)
}
@@ -168,16 +162,6 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
): CharSequence = if (isDmRoom) {
message
} else {
- prefix(message, senderDisambiguatedDisplayName)
- }
-
- private fun prefix(message: String, senderDisambiguatedDisplayName: String): AnnotatedString {
- return buildAnnotatedString {
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
- append(senderDisambiguatedDisplayName)
- }
- append(": ")
- append(message)
- }
+ message.prefixWith(senderDisambiguatedDisplayName)
}
}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt
new file mode 100644
index 0000000000..a5991e31df
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.eventformatter.impl
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+
+internal fun CharSequence.prefixWith(prefix: String): AnnotatedString {
+ return buildAnnotatedString {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(prefix)
+ }
+ append(": ")
+ append(this@prefixWith)
+ }
+}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
index ef15216a66..09cb6ef2ce 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
@@ -80,6 +80,15 @@ class StateContentFormatter @Inject constructor(
else -> sp.getString(R.string.state_event_room_topic_removed, senderDisambiguatedDisplayName)
}
}
+ is OtherState.RoomPinnedEvents -> when (renderingMode) {
+ RenderingMode.RoomList -> {
+ Timber.v("Filtering timeline item for room state change: $content")
+ null
+ }
+ RenderingMode.Timeline -> {
+ formatRoomPinnedEvents(content, senderIsYou, senderDisambiguatedDisplayName)
+ }
+ }
is OtherState.Custom -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")
@@ -161,15 +170,6 @@ class StateContentFormatter @Inject constructor(
"RoomJoinRules"
}
}
- OtherState.RoomPinnedEvents -> when (renderingMode) {
- RenderingMode.RoomList -> {
- Timber.v("Filtering timeline item for room state change: $content")
- null
- }
- RenderingMode.Timeline -> {
- "RoomPinnedEvents"
- }
- }
is OtherState.RoomUserPowerLevels -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")
@@ -217,4 +217,23 @@ class StateContentFormatter @Inject constructor(
}
}
}
+
+ private fun formatRoomPinnedEvents(
+ content: OtherState.RoomPinnedEvents,
+ senderIsYou: Boolean,
+ senderDisambiguatedDisplayName: String
+ ) = when (content.change) {
+ OtherState.RoomPinnedEvents.Change.ADDED -> when {
+ senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_pinned_by_you)
+ else -> sp.getString(R.string.state_event_room_pinned_events_pinned, senderDisambiguatedDisplayName)
+ }
+ OtherState.RoomPinnedEvents.Change.REMOVED -> when {
+ senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_unpinned_by_you)
+ else -> sp.getString(R.string.state_event_room_pinned_events_unpinned, senderDisambiguatedDisplayName)
+ }
+ OtherState.RoomPinnedEvents.Change.CHANGED -> when {
+ senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_changed_by_you)
+ else -> sp.getString(R.string.state_event_room_pinned_events_changed, senderDisambiguatedDisplayName)
+ }
+ }
}
diff --git a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
index dcc942e4fc..5f1722ad1a 100644
--- a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
@@ -45,6 +45,12 @@
"Вы выдалілі назву пакоя"
"%1$s не зрабіў(-ла) ніякіх змен"
"Вы не зрабілі ніякіх змен"
+ "%1$s змяніў(-ла) замацаваныя паведамленні"
+ "Вы змянілі замацаваныя паведамленні"
+ "%1$s замацаваў(-ла) паведамленне"
+ "Вы замацавалі паведамленне"
+ "%1$s адмацаваў(-ла) паведамленне"
+ "Вы адмацавалі паведамленне"
"%1$s адхіліў(-ла) запрашэнне"
"Вы адхілілі запрашэнне"
"%1$s выдаліў(-ла) %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
index e99778b71c..ab203f1d3b 100644
--- a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml
@@ -45,6 +45,12 @@
"Odstranili jste název místnosti"
"%1$s neprovedl(a) žádné změny"
"Neprovedli jste žádné změny"
+ "%1$s změnil(a) připnuté zprávy"
+ "Změnili jste připnuté zprávy"
+ "%1$s připnul(a) zprávu"
+ "Připnuli jste zprávu"
+ "%1$s odepnul(a) zprávu"
+ "Odepnuli jste zprávu"
"%1$s pozvánku odmítl(a)"
"Odmítli jste pozvání"
"%1$s odebral(a) %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-et/translations.xml b/libraries/eventformatter/impl/src/main/res/values-et/translations.xml
index 715453fbef..506f22db55 100644
--- a/libraries/eventformatter/impl/src/main/res/values-et/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-et/translations.xml
@@ -45,6 +45,12 @@
"Sina eemaldasid jututoa nime"
"%1$s ei teinud ühtegi muudatust"
"Sina ei teinud ühtegi muudatust"
+ "%1$s muutis esiletõstetud sõnumeid"
+ "Sina muutsid esiletõstetud sõnumeid"
+ "%1$s tõstis sõnumi esile"
+ "Sina tõstsid sõnumi esile"
+ "%1$s eemaldas esiletõstetud sõnumi"
+ "Sina eemaldasid esiletõstetud sõnumi"
"%1$s lükkas kutse tagasi"
"Sina lükkasid kutse tagasi"
"%1$s eemaldas jututoast kasutaja %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
index 664aeebeaf..7cc18968de 100644
--- a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml
@@ -45,6 +45,12 @@
"Vous avez supprimé le nom du salon"
"%1$s n‘a fait aucun changement visible"
"Vous n‘avez fait aucun changement visible"
+ "%1$s a modifié les messages épinglés"
+ "Vous avez modifié les messages épinglés"
+ "%1$s a épinglé un message"
+ "Vous avez épinglé un message"
+ "%1$s a désépinglé un message"
+ "Vous avez désépinglé un message"
"%1$s a rejeté l’invitation"
"Vous avez refusé l’invitation"
"%1$s a supprimé %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
index c23e973217..879e21c466 100644
--- a/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
@@ -45,6 +45,12 @@
"Eltávolította a szoba nevét"
"%1$s nem változtatott semmin"
"Nem változtatott semmin"
+ "%1$s megváltoztatta a kitűzött üzeneteket"
+ "Megváltoztatta a kitűzött üzeneteket"
+ "%1$s kitűzött egy üzenetet"
+ "Kitűzött egy üzenetet"
+ "%1$s feloldotta egy üzenet kitűzését"
+ "Feloldotta egy üzenet kitűzését"
"%1$s elutasította a meghívást"
"Elutasította a meghívást"
"%1$s eltávolította: %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
index 2d391404c7..02e1cebef0 100644
--- a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml
@@ -45,6 +45,12 @@
"Hai rimosso il nome della stanza"
"%1$s non ha apportato modifiche"
"Non hai apportato modifiche"
+ "%1$s ha modificato i messaggi fissati"
+ "Hai modificato i messaggi fissati"
+ "%1$s ha fissato un messaggio"
+ "Hai fissato un messaggio"
+ "%1$s ha rimosso un messaggio dai fissati"
+ "Hai rimosso un messaggio dai fissati"
"%1$s ha rifiutato l\'invito"
"Hai rifiutato l\'invito"
"%1$s ha rimosso %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..b10c806572
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,59 @@
+
+
+ "(afbeelding is ook gewijzigd)"
+ "%1$s wijzigde van afbeelding"
+ "Je hebt je afbeelding gewijzigd"
+ "%1$s heeft de weergavenaam aangepast van %2$s naar %3$s"
+ "Je hebt je weergavenaam aangepast van %1$s naar %2$s"
+ "%1$s heeft de weergavenaam verwijderd (dit was %2$s)"
+ "Je hebt je weergavenaam verwijderd (dit was %1$s)"
+ "%1$s heeft de weergavenaam %2$s aangenomen"
+ "Je hebt de weergavenaam %1$s aangenomen"
+ "%1$s heeft de kamerafbeelding gewijzigd"
+ "Je hebt de kamerafbeelding gewijzigd"
+ "%1$s heeft de kamerafbeelding verwijderd"
+ "Je hebt de kamerafbeelding verwijderd"
+ "%1$s heeft %2$s verbannen"
+ "Je hebt %1$s verbannen"
+ "%1$s heeft de kamer gemaakt"
+ "Je hebt de kamer gemaakt"
+ "%1$s heeft %2$s uitgenodigd"
+ "%1$s heeft de uitnodiging geaccepteerd"
+ "Je hebt de uitnodiging geaccepteerd"
+ "Jij hebt %1$s uitgenodigd"
+ "%1$s heeft je uitgenodigd"
+ "%1$s is tot de kamer toegetreden"
+ "Je bent toegetreden tot de kamer"
+ "%1$s heeft gevraagd om toe te treden"
+ "%1$s heeft %2$s toegestaan toe te treden"
+ "Je hebt %1$s toegestaan toe te treden"
+ "Je hebt gevraagd om toe te treden"
+ "%1$s heeft %2$s\'s verzoek om toe te treden afgewezen"
+ "Je hebt %1$s\'s verzoek om toe te treden afgewezen"
+ "%1$s heeft je verzoek om toe te treden afgewezen"
+ "%1$s wil niet meer toetreden"
+ "Je hebt je verzoek om toe te treden geannuleerd"
+ "%1$s verliet de kamer"
+ "Je hebt de kamer verlaten"
+ "%1$s heeft de kamernaam gewijzigd naar: %2$s"
+ "Je hebt de kamernaam gewijzigd naar: %1$s"
+ "%1$s heeft de kamernaam verwijderd"
+ "Je hebt de kamernaam verwijderd"
+ "%1$s heeft geen wijzigingen aangebracht"
+ "Je hebt geen wijzigingen aangebracht"
+ "%1$s heeft de uitnodiging afgewezen"
+ "Je hebt de uitnodiging afgewezen"
+ "%1$s heeft %2$s verwijderd"
+ "Je hebt %1$s verwijderd"
+ "%1$s heeft %2$s in deze kamer uitgenodigd"
+ "Je hebt %1$s in deze kamer uitgenodigd"
+ "%1$s heeft de uitnodiging aan %2$s om toe te treden tot de kamer ingetrokken"
+ "Je hebt de uitnodiging aan %1$s om toe te treden tot de kamer ingetrokken"
+ "%1$s heeft het onderwerp gewijzigd naar: %2$s"
+ "Je hebt het onderwerp gewijzigd naar: %1$s"
+ "%1$s heeft het kameronderwerp verwijderd"
+ "Je hebt het kamerondewerp verwijderd"
+ "%1$s heeft %2$s ontbannen"
+ "Jij hebt %1$s ontbannen"
+ "%1$s heeft een onbekende lidmaatschapswijziging"
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml
index 0c3bddc3c1..60f442bb75 100644
--- a/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml
@@ -3,12 +3,16 @@
"(zdjęcie profilowe też zostało zmienione)"
"%1$s zmienił swoje zdjęcie profilowe"
"Zmieniłeś swoje zdjęcie profilowe"
+ "%1$s został zdegradowany do członka"
+ "%1$s został zdegradowany do moderatora"
"%1$s zmienił swoją wyświetlaną nazwę z %2$s na %3$s"
"Zmieniłeś swoją wyświetlaną nazwę z %1$s na %2$s"
"%1$s usunął swoją wyświetlaną nazwę (byo to %2$s)"
"Usunąłeś swoją wyświetlaną nazwę (było to %1$s)"
"%1$s ustawił swoją wyświetlaną nazwę na %2$s"
"Ustawiłeś swoją wyświetlaną nazwę na %1$s"
+ "%1$s został awansowany na administratora"
+ "%1$s został awansowany na moderatora"
"%1$s zmienił zdjęcie profilowe pokoju"
"Zmieniłeś zdjęcie profilowe pokoju"
"%1$s usunął zdjęcie profilowe pokoju"
@@ -41,8 +45,14 @@
"Usunąłeś nazwę pokoju"
"%1$s nie wprowadził żadnych zmian"
"Nie wprowadzono żadnych zmian"
+ "%1$s zmienił przypięte wiadomości"
+ "Zmieniłeś przypięte wiadomości"
+ "%1$s przypiął wiadomość"
+ "Przypiąłeś wiadomość"
+ "%1$s odpiął wiadomość"
+ "Odpiąłeś wiadomość"
"%1$s odrzucił zaproszenie"
- "Odrzuciłeś(aś) zaproszenie"
+ "Odrzuciłeś zaproszenie"
"%1$s usunął %2$s"
"Usunąłeś %1$s"
"%1$s wysłał zaproszenie do %2$s, aby dołączył do pokoju"
diff --git a/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml
index 8cb375f1b7..5148cc7638 100644
--- a/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,12 +3,16 @@
"(o avatar também foi alterado)"
"%1$s mudou seu avatar"
"Você mudou seu avatar"
+ "%1$s foi rebaixado a membro"
+ "%1$s foi rebaixado a moderador"
"%1$s mudou seu nome de exibição de %2$s para %3$s"
"Você alterou seu nome de exibição de %1$s para %2$s"
"%1$s removeu seu nome de exibição (era %2$s)"
"Você removeu seu nome de exibição (era %1$s)"
"%1$s definiu seu nome de exibição como %2$s"
"Você definiu seu nome de exibição como %1$s"
+ "%1$s foi promovido a administrador"
+ "%1$s foi promovido a moderador"
"%1$s mudou o avatar da sala"
"Você mudou o avatar da sala"
"%1$s removeu o avatar da sala"
@@ -39,6 +43,8 @@
"Você mudou o nome da sala para: %1$s"
"%1$s removeu o nome da sala"
"Você removeu o nome da sala"
+ "%1$s não fez alterações"
+ "Você não fez nenhuma alteração"
"%1$s rejeitou o convite"
"Você rejeitou o convite"
"%1$s removido %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml
index 6d214ab03e..9d21c1949c 100644
--- a/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml
@@ -45,6 +45,12 @@
"Removeste o nome da sala"
"%1$s não fiz nenhuma alteração"
"Não fizeste nenhuma alteração"
+ "%1$s alterou as mensagens afixadas"
+ "Alteraste as mensagens afixadas"
+ "%1$s afixou uma mensagem"
+ "Afixaste uma mensagem"
+ "%1$s desafixou uma mensagem"
+ "Desafixaste uma mensagem"
"%1$s rejeitou o convite"
"Rejeitaste o convite"
"%1$s removeu %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
index a6a339bba6..385630ef4c 100644
--- a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
@@ -45,6 +45,12 @@
"Вы удалили название комнаты"
"%1$s ничего не изменил"
"Вы не внесли никаких изменений"
+ "%1$s изменил закрепленные сообщения"
+ "Вы изменили закрепленные сообщения"
+ "%1$s закрепил сообщение"
+ "Вы закрепили сообщение"
+ "%1$s открепил сообщение"
+ "Вы открепили сообщение"
"%1$s отклонил приглашение"
"Вы отклонили приглашение"
"%1$s удалил %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml
index 9a9f41a3fb..5e9976b80a 100644
--- a/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml
@@ -45,6 +45,12 @@
"Odstránili ste názov miestnosti"
"%1$s nevykonal/a žiadne zmeny"
"Nevykonali ste žiadne zmeny"
+ "%1$s zmenil/a pripnuté správy"
+ "Zmenili ste pripnuté správy"
+ "%1$s pripol/la správu"
+ "Pripli ste správu"
+ "%1$s zrušil/a pripnutie správy"
+ "Zrušili ste pripnutie správy"
"%1$s odmietol/a pozvánku"
"Odmietli ste pozvánku"
"%1$s odstránil/a %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
index 014f21ee4a..d96f722111 100644
--- a/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
@@ -45,6 +45,12 @@
"Du tog bort rummets namn"
"%1$s gjorde inga ändringar"
"Du gjorde inga ändringar"
+ "%1$s ändrade de fästa meddelandena"
+ "Du ändrade de fästa meddelandena"
+ "%1$s fäste ett meddelande"
+ "Du har fäste ett meddelande"
+ "%1$s lossade ett meddelande"
+ "Du har lossade ett meddelande"
"%1$s avvisade inbjudan"
"Du avvisade inbjudan"
"%1$s tog bort %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
index eccaa950eb..065f08cad4 100644
--- a/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
@@ -30,7 +30,7 @@
"Ви приєдналися до кімнати"
"%1$s подав (-ла) запит на приєднання"
"%1$s дозволив (-ла) %2$s приєднатися"
- "%1$s дозволив (-ла) Вам приєднатися"
+ "Ви дозволили %1$s приєднатися"
"Ви подали запит на приєднання"
"%1$s відхилив (-ла) запит %2$s на приєднання"
"Ви відхилили запит %1$s на приєднання"
@@ -45,6 +45,12 @@
"Ви видалили назву кімнати"
"%1$s не внесено жодних змін"
"Ви не внесли жодних змін"
+ "%1$s змінив(-ла) закріплені повідомлення"
+ "Ви змінили закріплені повідомлення"
+ "%1$s закріпив(-ла) повідомлення"
+ "Ви закріпили повідомлення"
+ "%1$s відкріпив(-ла) повідомлення"
+ "Ви відкріпили повідомлення"
"%1$s відхилив (-ла) запрошення"
"Ви відхилили запрошення"
"%1$s вилучив (-ла) %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml b/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..91ac192205
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "(avatar ham o\'zgartirildi)"
+ "%1$s avatarini o\'zgartirdi"
+ "Siz avataringizni o\'zgartirdingiz"
+ "%1$s ko\'rsatiladigan nomini %2$sdan %3$sga o\'zgartirdi"
+ "Siz ko\'rsatiladigan nomingizni %1$s dan %2$s ga o\'zgartirdingiz"
+ "%1$s ko\'rinadigan nomini o\'chirib tashladi (avval %2$s bo\'lgan edi)"
+ "Siz ko\'rinadigan nomingizni o\'chirib tashladingiz (avval %1$s bo\'lgan edi)"
+ "%1$s ularning ko\'rsatiladigan nomini o\'rnating %2$s"
+ "Siz ko\'rsatiladigan nomingizni o\'rnating %1$s"
+ "%1$s xonani avatarini o\'zgartirdi"
+ "Siz xonani avatarini o\'zgartirdingiz"
+ "%1$s xonani avatarini o\'chirib tashladi"
+ "Siz xonani avatarini o\'chirib tashladingiz"
+ "%1$staqiqlangan%2$s"
+ "Siz taqiqlangansiz%1$s"
+ "%1$sxonani yaratdi"
+ "Siz xonani yaratdingiz"
+ "%1$staklif qilingan%2$s"
+ "%1$staklifni qabul qildi"
+ "Siz taklifni qabul qildingiz"
+ "Siz taklif qildingiz%1$s"
+ "%1$ssizni taklif qildi"
+ "%1$sxonaga qo\'shildi"
+ "Siz xonaga qo\'shildingiz"
+ "%1$s qo\'shilishni so\'radi"
+ "%1$s %2$sga qo\'shilishga ruxsat berdi"
+ "Siz %1$sga qo\'shilishaga ruxsat berdingiz"
+ "Siz qoʻshilishni soʻragansiz"
+ "%1$s %2$sning qo\'shilish haqidagi iltimosini rad etdi"
+ "Siz %1$sning qo\'shiliz iltimosini rad etdingiz"
+ "%1$s sizni qo\'shilish iltimosingizni rad etdi"
+ "%1$s endi qo\'shilishdan manfaatdor emas"
+ "Siz qoʻshilish soʻrovingizni bekor qildingiz"
+ "%1$sxonani tark etdi"
+ "Siz xonani tark etdingiz"
+ "%1$s xonani nomini %2$s o\'zgartirdi"
+ "Siz xonani nomini %1$s ga o\'zgartirdingiz"
+ "%1$s xonani nomini o\'chirib tashladi"
+ "Siz xonani nomini o\'chirib tashladingiz"
+ "%1$staklifni rad etdi"
+ "Siz taklifni rad etdingiz"
+ "%1$s o\'chirildi %2$s"
+ "Siz o\'chirildingiz %1$s"
+ "%1$s taklifnoma yubordi %2$sga xonaga qo\'shilish uchun"
+ "Siz taklifnoma yubordingiz %1$s ga xonaga qo\'shilishi uchun"
+ "%1$s taklifni %2$s ga xonaga qo\'shilish uchun bekor qildi"
+ "Siz xonaga qo\'shilish taklifini %1$s ga bekor qildingiz"
+ "%1$s mavzuni %2$s o\'zgartirdi"
+ "Siz mavzuni %1$s ga o\'zgartirdingiz"
+ "%1$s xonani mavzusini o\'chirib tashladi"
+ "Siz xonani mavzusini o\'chirib tashladingiz"
+ "%1$staqiqlanmagan%2$s"
+ "Siz %1$s taqiqini bekor qildingiz"
+ "%1$s aʼzoligiga nomaʼlum oʻzgarishlar kiritdi"
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml
index 76a61c6ccc..c968d6c75c 100644
--- a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml
@@ -45,6 +45,12 @@
"你移除了房间名称"
"%1$s 没有任何更改"
"您未进行任何更改"
+ "%1$s 更改了置顶消息"
+ "您更改了置顶消息"
+ "%1$s 置顶了一条消息"
+ "您置顶了一条消息"
+ "%1$s 取消置顶了一条消息"
+ "您取消置顶了一条消息"
"%1$s 拒绝了邀请"
"你拒绝了邀请"
"%1$s 移除了 %2$s"
diff --git a/libraries/eventformatter/impl/src/main/res/values/localazy.xml b/libraries/eventformatter/impl/src/main/res/values/localazy.xml
index 5e06d74e92..12dbc16a1d 100644
--- a/libraries/eventformatter/impl/src/main/res/values/localazy.xml
+++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml
@@ -45,6 +45,12 @@
"You removed the room name"
"%1$s made no changes"
"You made no changes"
+ "%1$s changed the pinned messages"
+ "You changed the pinned messages"
+ "%1$s pinned a message"
+ "You pinned a message"
+ "%1$s unpinned a message"
+ "You unpinned a message"
"%1$s rejected the invitation"
"You rejected the invitation"
"%1$s removed %2$s"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
new file mode 100644
index 0000000000..2bf96de7c1
--- /dev/null
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
@@ -0,0 +1,799 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.eventformatter.impl
+
+import android.content.Context
+import androidx.compose.ui.text.AnnotatedString
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
+import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
+import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
+import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.media.aMediaSource
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.test.timeline.aPollContent
+import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
+import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@Suppress("LargeClass")
+@RunWith(RobolectricTestRunner::class)
+class DefaultPinnedMessagesBannerFormatterTest {
+ private lateinit var context: Context
+ private lateinit var fakeMatrixClient: FakeMatrixClient
+ private lateinit var formatter: DefaultPinnedMessagesBannerFormatter
+ private lateinit var unsupportedEvent: String
+
+ @Before
+ fun setup() {
+ context = RuntimeEnvironment.getApplication() as Context
+ fakeMatrixClient = FakeMatrixClient()
+ val stringProvider = AndroidStringProvider(context.resources)
+ formatter = DefaultPinnedMessagesBannerFormatter(
+ sp = stringProvider,
+ permalinkParser = FakePermalinkParser(),
+ )
+ unsupportedEvent = stringProvider.getString(CommonStrings.common_unsupported_event)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Redacted content`() {
+ val expected = "Message removed"
+ val senderName = "Someone"
+ val message = createRoomEvent(false, senderName, RedactedContent)
+ val result = formatter.format(message)
+ assertThat(result).isEqualTo(expected)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Sticker content`() {
+ val body = "a sticker body"
+ val info = ImageInfo(null, null, null, null, null, null, null)
+ val message = createRoomEvent(false, null, StickerContent(body, info, aMediaSource(url = "url")))
+ val result = formatter.format(message)
+ val expectedBody = "Sticker: a sticker body"
+ assertThat(result.toString()).isEqualTo(expectedBody)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Unable to decrypt content`() {
+ val expected = "Waiting for this message"
+ val senderName = "Someone"
+ val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown))
+ val result = formatter.format(message)
+ assertThat(result).isEqualTo(expected)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `FailedToParseMessageLike, FailedToParseState & Unknown content`() {
+ val senderName = "Someone"
+ sequenceOf(
+ FailedToParseMessageLikeContent("", ""),
+ FailedToParseStateContent("", "", ""),
+ UnknownContent,
+ ).forEach { type ->
+ val message = createRoomEvent(false, senderName, type)
+ val result = formatter.format(message)
+ assertWithMessage("$type was not properly handled").that(result).isEqualTo(unsupportedEvent)
+ }
+ }
+
+ // region Message contents
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Message contents`() {
+ val body = "Shared body"
+ fun createMessageContent(type: MessageType): MessageContent {
+ return MessageContent(body, null, false, false, type)
+ }
+
+ val sharedContentMessagesTypes = arrayOf(
+ TextMessageType(body, null),
+ VideoMessageType(body, null, null, MediaSource("url"), null),
+ AudioMessageType(body, MediaSource("url"), null),
+ VoiceMessageType(body, MediaSource("url"), null, null),
+ ImageMessageType(body, null, null, MediaSource("url"), null),
+ StickerMessageType(body, MediaSource("url"), null),
+ FileMessageType(body, MediaSource("url"), null),
+ LocationMessageType(body, "geo:1,2", null),
+ NoticeMessageType(body, null),
+ EmoteMessageType(body, null),
+ OtherMessageType(msgType = "a_type", body = body),
+ )
+ val results = mutableListOf>()
+
+ sharedContentMessagesTypes.forEach { type ->
+ val content = createMessageContent(type)
+ val message = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content)
+ val result = formatter.format(message)
+ results.add(type to result)
+ }
+
+ // Verify results type
+ for ((type, result) in results) {
+ val expectedResult = when (type) {
+ is VideoMessageType,
+ is AudioMessageType,
+ is VoiceMessageType,
+ is ImageMessageType,
+ is StickerMessageType,
+ is FileMessageType,
+ is LocationMessageType -> AnnotatedString::class.java
+ is EmoteMessageType,
+ is TextMessageType,
+ is NoticeMessageType,
+ is OtherMessageType -> String::class.java
+ }
+ assertThat(result).isInstanceOf(expectedResult)
+ }
+ // Verify results content
+ for ((type, result) in results) {
+ val expectedResult = when (type) {
+ is VideoMessageType -> "Video: Shared body"
+ is AudioMessageType -> "Audio: Shared body"
+ is VoiceMessageType -> "Voice message: Shared body"
+ is ImageMessageType -> "Image: Shared body"
+ is StickerMessageType -> "Sticker: Shared body"
+ is FileMessageType -> "File: Shared body"
+ is LocationMessageType -> "Shared location: Shared body"
+ is EmoteMessageType -> "* Someone ${type.body}"
+ is TextMessageType,
+ is NoticeMessageType,
+ is OtherMessageType -> body
+ }
+ assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo(expectedResult)
+ }
+ }
+
+ // endregion
+
+ // region Membership change
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - joined`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.JOINED)
+
+ val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youJoinedRoom = formatter.format(youJoinedRoomEvent)
+ assertThat(youJoinedRoom).isEqualTo(unsupportedEvent)
+
+ val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent)
+ assertThat(someoneJoinedRoom).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - left`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.LEFT)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.LEFT)
+
+ val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youLeftRoom = formatter.format(youLeftRoomEvent)
+ assertThat(youLeftRoom).isEqualTo(unsupportedEvent)
+
+ val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneLeftRoom = formatter.format(someoneLeftRoomEvent)
+ assertThat(someoneLeftRoom).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - banned`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED)
+ val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED)
+ val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED)
+
+ val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youBanned = formatter.format(youBannedEvent)
+ assertThat(youBanned).isEqualTo(unsupportedEvent)
+
+ val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent)
+ val youKickedBanned = formatter.format(youKickBannedEvent)
+ assertThat(youKickedBanned).isEqualTo(unsupportedEvent)
+
+ val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneBanned = formatter.format(someoneBannedEvent)
+ assertThat(someoneBanned).isEqualTo(unsupportedEvent)
+
+ val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent)
+ val someoneKickBanned = formatter.format(someoneKickBannedEvent)
+ assertThat(someoneKickBanned).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - unban`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED)
+
+ val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youUnbanned = formatter.format(youUnbannedEvent)
+ assertThat(youUnbanned).isEqualTo(unsupportedEvent)
+
+ val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneUnbanned = formatter.format(someoneUnbannedEvent)
+ assertThat(someoneUnbanned).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - kicked`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED)
+
+ val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youKicked = formatter.format(youKickedEvent)
+ assertThat(youKicked).isEqualTo(unsupportedEvent)
+
+ val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneKicked = formatter.format(someoneKickedEvent)
+ assertThat(someoneKicked).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - invited`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITED)
+
+ val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent)
+ val youWereInvited = formatter.format(youWereInvitedEvent)
+ assertThat(youWereInvited).isEqualTo(unsupportedEvent)
+
+ val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
+ val youInvited = formatter.format(youInvitedEvent)
+ assertThat(youInvited).isEqualTo(unsupportedEvent)
+
+ val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneInvited = formatter.format(someoneInvitedEvent)
+ assertThat(someoneInvited).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - invitation accepted`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_ACCEPTED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_ACCEPTED)
+
+ val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youAcceptedInvite = formatter.format(youAcceptedInviteEvent)
+ assertThat(youAcceptedInvite).isEqualTo(unsupportedEvent)
+
+ val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent)
+ assertThat(someoneAcceptedInvite).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - invitation rejected`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_REJECTED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_REJECTED)
+
+ val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youRejectedInvite = formatter.format(youRejectedInviteEvent)
+ assertThat(youRejectedInvite).isEqualTo(unsupportedEvent)
+
+ val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent)
+ assertThat(someoneRejectedInvite).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - invitation revoked`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITATION_REVOKED)
+
+ val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
+ val youRevokedInvite = formatter.format(youRevokedInviteEvent)
+ assertThat(youRevokedInvite).isEqualTo(unsupportedEvent)
+
+ val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent)
+ assertThat(someoneRevokedInvite).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - knocked`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCKED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.KNOCKED)
+
+ val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youKnocked = formatter.format(youKnockedEvent)
+ assertThat(youKnocked).isEqualTo(unsupportedEvent)
+
+ val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneKnocked = formatter.format(someoneKnockedEvent)
+ assertThat(someoneKnocked).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - knock accepted`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_ACCEPTED)
+
+ val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
+ val youAcceptedKnock = formatter.format(youAcceptedKnockEvent)
+ assertThat(youAcceptedKnock).isEqualTo(unsupportedEvent)
+
+ val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent)
+ assertThat(someoneAcceptedKnock).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - knock retracted`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCK_RETRACTED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), null, MembershipChange.KNOCK_RETRACTED)
+
+ val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youRetractedKnock = formatter.format(youRetractedKnockEvent)
+ assertThat(youRetractedKnock).isEqualTo(unsupportedEvent)
+
+ val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent)
+ assertThat(someoneRetractedKnock).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - knock denied`() {
+ val otherName = "Other"
+ val third = "Someone"
+ val youContent = RoomMembershipContent(A_USER_ID, third, MembershipChange.KNOCK_DENIED)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_DENIED)
+
+ val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
+ val youDeniedKnock = formatter.format(youDeniedKnockEvent)
+ assertThat(youDeniedKnock).isEqualTo(unsupportedEvent)
+
+ val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent)
+ assertThat(someoneDeniedKnock).isEqualTo(unsupportedEvent)
+
+ val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent)
+ val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent)
+ assertThat(someoneDeniedYourKnock).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - None`() {
+ val otherName = "Other"
+ val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.NONE)
+ val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.NONE)
+
+ val youNoneRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent)
+ val youNoneRoom = formatter.format(youNoneRoomEvent)
+ assertThat(youNoneRoom).isEqualTo(unsupportedEvent)
+
+ val someoneNoneRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
+ val someoneNoneRoom = formatter.format(someoneNoneRoomEvent)
+ assertThat(someoneNoneRoom).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Membership change - others`() {
+ val otherChanges = arrayOf(MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED, null)
+
+ val results = otherChanges.map { change ->
+ val content = RoomMembershipContent(A_USER_ID, null, change)
+ val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content)
+ val result = formatter.format(event)
+ change to result
+ }
+ val expected = otherChanges.map { it to unsupportedEvent }
+ assertThat(results).isEqualTo(expected)
+ }
+
+ // endregion
+
+ // region Room State
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - avatar`() {
+ val otherName = "Other"
+ val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar"))
+ val removedContent = StateContent("", OtherState.RoomAvatar(null))
+
+ val youChangedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedRoomAvatar = formatter.format(youChangedRoomAvatarEvent)
+ assertThat(youChangedRoomAvatar).isEqualTo(unsupportedEvent)
+
+ val someoneChangedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneChangedRoomAvatar = formatter.format(someoneChangedRoomAvatarEvent)
+ assertThat(someoneChangedRoomAvatar).isEqualTo(unsupportedEvent)
+
+ val youRemovedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youRemovedRoomAvatar = formatter.format(youRemovedRoomAvatarEvent)
+ assertThat(youRemovedRoomAvatar).isEqualTo(unsupportedEvent)
+
+ val someoneRemovedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneRemovedRoomAvatar = formatter.format(someoneRemovedRoomAvatarEvent)
+ assertThat(someoneRemovedRoomAvatar).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - create`() {
+ val otherName = "Other"
+ val content = StateContent("", OtherState.RoomCreate)
+
+ val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content)
+ val youCreatedRoom = formatter.format(youCreatedRoomMessage)
+ assertThat(youCreatedRoom).isEqualTo(unsupportedEvent)
+
+ val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content)
+ val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent)
+ assertThat(someoneCreatedRoom).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - encryption`() {
+ val otherName = "Other"
+ val content = StateContent("", OtherState.RoomEncryption)
+
+ val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content)
+ val youCreatedRoom = formatter.format(youCreatedRoomMessage)
+ assertThat(youCreatedRoom).isEqualTo(unsupportedEvent)
+
+ val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content)
+ val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent)
+ assertThat(someoneCreatedRoom).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - room name`() {
+ val otherName = "Other"
+ val newName = "New name"
+ val changedContent = StateContent("", OtherState.RoomName(newName))
+ val removedContent = StateContent("", OtherState.RoomName(null))
+
+ val youChangedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedRoomName = formatter.format(youChangedRoomNameEvent)
+ assertThat(youChangedRoomName).isEqualTo(unsupportedEvent)
+
+ val someoneChangedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneChangedRoomName = formatter.format(someoneChangedRoomNameEvent)
+ assertThat(someoneChangedRoomName).isEqualTo(unsupportedEvent)
+
+ val youRemovedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youRemovedRoomName = formatter.format(youRemovedRoomNameEvent)
+ assertThat(youRemovedRoomName).isEqualTo(unsupportedEvent)
+
+ val someoneRemovedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneRemovedRoomName = formatter.format(someoneRemovedRoomNameEvent)
+ assertThat(someoneRemovedRoomName).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - third party invite`() {
+ val otherName = "Other"
+ val inviteeName = "Alice"
+ val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName))
+ val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null))
+
+ val youInvitedSomeoneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youInvitedSomeone = formatter.format(youInvitedSomeoneEvent)
+ assertThat(youInvitedSomeone).isEqualTo(unsupportedEvent)
+
+ val someoneInvitedSomeoneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneInvitedSomeone = formatter.format(someoneInvitedSomeoneEvent)
+ assertThat(someoneInvitedSomeone).isEqualTo(unsupportedEvent)
+
+ val youInvitedNoOneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youInvitedNoOne = formatter.format(youInvitedNoOneEvent)
+ assertThat(youInvitedNoOne).isEqualTo(unsupportedEvent)
+
+ val someoneInvitedNoOneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneInvitedNoOne = formatter.format(someoneInvitedNoOneEvent)
+ assertThat(someoneInvitedNoOne).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - room topic`() {
+ val otherName = "Other"
+ val roomTopic = "New topic"
+ val changedContent = StateContent("", OtherState.RoomTopic(roomTopic))
+ val removedContent = StateContent("", OtherState.RoomTopic(null))
+
+ val youChangedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedRoomTopic = formatter.format(youChangedRoomTopicEvent)
+ assertThat(youChangedRoomTopic).isEqualTo(unsupportedEvent)
+
+ val someoneChangedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneChangedRoomTopic = formatter.format(someoneChangedRoomTopicEvent)
+ assertThat(someoneChangedRoomTopic).isEqualTo(unsupportedEvent)
+
+ val youRemovedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youRemovedRoomTopic = formatter.format(youRemovedRoomTopicEvent)
+ assertThat(youRemovedRoomTopic).isEqualTo(unsupportedEvent)
+
+ val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent)
+ assertThat(someoneRemovedRoomTopic).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Room state change - others must return null`() {
+ val otherStates = arrayOf(
+ OtherState.PolicyRuleRoom,
+ OtherState.PolicyRuleServer,
+ OtherState.PolicyRuleUser,
+ OtherState.RoomAliases,
+ OtherState.RoomCanonicalAlias,
+ OtherState.RoomGuestAccess,
+ OtherState.RoomHistoryVisibility,
+ OtherState.RoomJoinRules,
+ OtherState.RoomPinnedEvents(OtherState.RoomPinnedEvents.Change.CHANGED),
+ OtherState.RoomUserPowerLevels(emptyMap()),
+ OtherState.RoomServerAcl,
+ OtherState.RoomTombstone,
+ OtherState.SpaceChild,
+ OtherState.SpaceParent,
+ OtherState.Custom("custom_event_type")
+ )
+
+ val results = otherStates.map { state ->
+ val content = StateContent("", state)
+ val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content)
+ val result = formatter.format(event)
+ state to result
+ }
+ val expected = otherStates.map { it to unsupportedEvent }
+ assertThat(results).isEqualTo(expected)
+ }
+
+ // endregion
+
+ // region Profile change
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Profile change - avatar`() {
+ val otherName = "Other"
+ val changedContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = "old_avatar_url")
+ val setContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = null)
+ val removedContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = "old_avatar_url")
+ val invalidContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = null)
+ val sameContent = aProfileChangeMessageContent(avatarUrl = "same_avatar_url", prevAvatarUrl = "same_avatar_url")
+
+ val youChangedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedAvatar = formatter.format(youChangedAvatarEvent)
+ assertThat(youChangedAvatar).isEqualTo(unsupportedEvent)
+
+ val someoneChangeAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneChangeAvatar = formatter.format(someoneChangeAvatarEvent)
+ assertThat(someoneChangeAvatar).isEqualTo(unsupportedEvent)
+
+ val youSetAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent)
+ val youSetAvatar = formatter.format(youSetAvatarEvent)
+ assertThat(youSetAvatar).isEqualTo(unsupportedEvent)
+
+ val someoneSetAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent)
+ val someoneSetAvatar = formatter.format(someoneSetAvatarEvent)
+ assertThat(someoneSetAvatar).isEqualTo(unsupportedEvent)
+
+ val youRemovedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youRemovedAvatar = formatter.format(youRemovedAvatarEvent)
+ assertThat(youRemovedAvatar).isEqualTo(unsupportedEvent)
+
+ val someoneRemovedAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneRemovedAvatar = formatter.format(someoneRemovedAvatarEvent)
+ assertThat(someoneRemovedAvatar).isEqualTo(unsupportedEvent)
+
+ val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent)
+ val unchangedResult = formatter.format(unchangedEvent)
+ assertThat(unchangedResult).isEqualTo(unsupportedEvent)
+
+ val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent)
+ val invalidResult = formatter.format(invalidEvent)
+ assertThat(invalidResult).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Profile change - display name`() {
+ val newDisplayName = "New"
+ val oldDisplayName = "Old"
+ val otherName = "Other"
+ val changedContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = oldDisplayName)
+ val setContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = null)
+ val removedContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = oldDisplayName)
+ val sameContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = newDisplayName)
+ val invalidContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = null)
+
+ val youChangedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedDisplayName = formatter.format(youChangedDisplayNameEvent)
+ assertThat(youChangedDisplayName).isEqualTo(unsupportedEvent)
+
+ val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
+ val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent)
+ assertThat(someoneChangedDisplayName).isEqualTo(unsupportedEvent)
+
+ val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent)
+ val youSetDisplayName = formatter.format(youSetDisplayNameEvent)
+ assertThat(youSetDisplayName).isEqualTo(unsupportedEvent)
+
+ val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent)
+ val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent)
+ assertThat(someoneSetDisplayName).isEqualTo(unsupportedEvent)
+
+ val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
+ val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent)
+ assertThat(youRemovedDisplayName).isEqualTo(unsupportedEvent)
+
+ val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
+ val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent)
+ assertThat(someoneRemovedDisplayName).isEqualTo(unsupportedEvent)
+
+ val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent)
+ val unchangedResult = formatter.format(unchangedEvent)
+ assertThat(unchangedResult).isEqualTo(unsupportedEvent)
+
+ val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent)
+ val invalidResult = formatter.format(invalidEvent)
+ assertThat(invalidResult).isEqualTo(unsupportedEvent)
+ }
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Profile change - display name & avatar`() {
+ val newDisplayName = "New"
+ val oldDisplayName = "Old"
+ val changedContent = aProfileChangeMessageContent(
+ displayName = newDisplayName,
+ prevDisplayName = oldDisplayName,
+ avatarUrl = "new_avatar_url",
+ prevAvatarUrl = "old_avatar_url",
+ )
+ val invalidContent = aProfileChangeMessageContent(
+ displayName = null,
+ prevDisplayName = null,
+ avatarUrl = null,
+ prevAvatarUrl = null,
+ )
+ val sameContent = aProfileChangeMessageContent(
+ displayName = newDisplayName,
+ prevDisplayName = newDisplayName,
+ avatarUrl = "same_avatar_url",
+ prevAvatarUrl = "same_avatar_url",
+ )
+
+ val youChangedBothEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent)
+ val youChangedBoth = formatter.format(youChangedBothEvent)
+ assertThat(youChangedBoth).isEqualTo(unsupportedEvent)
+
+ val invalidContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = invalidContent)
+ val invalidMessage = formatter.format(invalidContentEvent)
+ assertThat(invalidMessage).isEqualTo(unsupportedEvent)
+
+ val sameContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = sameContent)
+ val sameMessage = formatter.format(sameContentEvent)
+ assertThat(sameMessage).isEqualTo(unsupportedEvent)
+ }
+
+ // endregion
+
+ // region Polls
+
+ @Test
+ @Config(qualifiers = "en")
+ fun `Computes last message for poll`() {
+ val pollContent = aPollContent()
+
+ val mineContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = "Alice", content = pollContent)
+ val result = formatter.format(mineContentEvent)
+ assertThat(result).isInstanceOf(AnnotatedString::class.java)
+ assertThat(result.toString()).isEqualTo("Poll: Do you like polls?")
+
+ val contentEvent = createRoomEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent)
+ val result2 = formatter.format(contentEvent)
+ assertThat(result2).isInstanceOf(AnnotatedString::class.java)
+ assertThat(result2.toString()).isEqualTo("Poll: Do you like polls?")
+ }
+
+ // endregion
+
+ private fun createRoomEvent(
+ sentByYou: Boolean,
+ senderDisplayName: String?,
+ content: EventContent,
+ ): EventTimelineItem {
+ val sender = if (sentByYou) A_USER_ID else someoneElseId
+ val profile = aProfileTimelineDetails(senderDisplayName)
+ return anEventTimelineItem(
+ content = content,
+ senderProfile = profile,
+ sender = sender,
+ isOwn = sentByYou,
+ )
+ }
+
+ private val someoneElseId = UserId("@someone_else:domain")
+}
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index 5465882479..3f742c0954 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -660,7 +660,7 @@ class DefaultRoomLastMessageFormatterTest {
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,
OtherState.RoomJoinRules,
- OtherState.RoomPinnedEvents,
+ OtherState.RoomPinnedEvents(OtherState.RoomPinnedEvents.Change.CHANGED),
OtherState.RoomUserPowerLevels(emptyMap()),
OtherState.RoomServerAcl,
OtherState.RoomTombstone,
diff --git a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt
new file mode 100644
index 0000000000..668803169e
--- /dev/null
+++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.eventformatter.test
+
+import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+
+class FakePinnedMessagesBannerFormatter(
+ val formatLambda: (event: EventTimelineItem) -> CharSequence
+) : PinnedMessagesBannerFormatter {
+ override fun format(event: EventTimelineItem): CharSequence {
+ return formatLambda(event)
+ }
+}
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 616a549e63..971d99c095 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -72,6 +72,13 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
+ RoomAliasSuggestions(
+ key = "feature.roomAliasSuggestions",
+ title = "Room alias suggestions",
+ description = "Type `#` to get room alias suggestions and insert them",
+ defaultValue = { false },
+ isFinished = false,
+ ),
MarkAsUnread(
key = "feature.markAsUnread",
title = "Mark as unread",
@@ -113,11 +120,18 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
- PictureInPicture(
- key = "feature.pictureInPicture",
- title = "Picture in Picture for Calls",
- description = "Allow the Call to be rendered in PiP mode",
- defaultValue = { it.buildType != BuildType.RELEASE },
+ PinnedEvents(
+ key = "feature.pinnedEvents",
+ title = "Pinned Events",
+ description = "Allow user to pin events in a room",
+ defaultValue = { false },
+ isFinished = false,
+ ),
+ SyncOnPush(
+ key = "feature.syncOnPush",
+ title = "Sync on push",
+ description = "Subscribe to room sync when a push is received",
+ defaultValue = { true },
isFinished = false,
),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index b3b47d4499..3209d49e03 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.api
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -65,8 +67,8 @@ interface MatrixClient : Closeable {
suspend fun setDisplayName(displayName: String): Result
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result
suspend fun removeAvatar(): Result
- suspend fun joinRoom(roomId: RoomId): Result
- suspend fun joinRoomByIdOrAlias(roomId: RoomId, serverNames: List): Result
+ suspend fun joinRoom(roomId: RoomId): Result
+ suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result
suspend fun knockRoom(roomId: RoomId): Result
fun syncService(): SyncService
fun sessionVerificationService(): SessionVerificationService
@@ -104,7 +106,6 @@ interface MatrixClient : Closeable {
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result
suspend fun getRecentlyVisitedRooms(): Result>
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result
- suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result
/**
* Enables or disables the sending queue, according to the given parameter.
@@ -132,4 +133,5 @@ interface MatrixClient : Closeable {
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result
+ suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
index f47487c634..5afb81648b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
@@ -62,4 +62,52 @@ interface EncryptionService {
* called the fingerprint of the device.
*/
suspend fun deviceEd25519(): String?
+
+ /**
+ * Starts the identity reset process. This will return a handle that can be used to reset the identity.
+ */
+ suspend fun startIdentityReset(): Result
+}
+
+/**
+ * A handle to reset the user's identity.
+ */
+interface IdentityResetHandle {
+ /**
+ * Cancel the reset process and drops the existing handle in the SDK.
+ */
+ suspend fun cancel()
+}
+
+/**
+ * A handle to reset the user's identity with a password login type.
+ */
+interface IdentityPasswordResetHandle : IdentityResetHandle {
+ /**
+ * Reset the password of the user.
+ *
+ * This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is
+ * called, or the identity is reset.
+ *
+ * @param password the current password, which will be validated before the process takes place.
+ */
+ suspend fun resetPassword(password: String): Result
+}
+
+/**
+ * A handle to reset the user's identity with an OIDC login type.
+ */
+interface IdentityOidcResetHandle : IdentityResetHandle {
+ /**
+ * The URL to open in a webview/custom tab to reset the identity.
+ */
+ val url: String
+
+ /**
+ * Reset the identity using the OIDC flow.
+ *
+ * This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is
+ * called, or the identity is reset.
+ */
+ suspend fun resetOidc(): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 1696375bcb..74bd2d8e14 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -53,6 +53,7 @@ sealed interface NotificationContent {
data class CallInvite(
val senderId: UserId,
) : MessageLike
+
data class CallNotify(
val senderId: UserId,
val type: CallNotifyType,
@@ -77,7 +78,11 @@ sealed interface NotificationContent {
val messageType: MessageType
) : MessageLike
- data object RoomRedaction : MessageLike
+ data class RoomRedaction(
+ val redactedEventId: String?,
+ val reason: String?
+ ) : MessageLike
+
data object Sticker : MessageLike
data class Poll(
val senderId: UserId,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
index 5e562f43c5..a7b2bf26cf 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
@@ -25,6 +25,5 @@ interface PermalinkBuilder {
}
sealed class PermalinkBuilderError : Throwable() {
- data object InvalidUserId : PermalinkBuilderError()
- data object InvalidRoomAlias : PermalinkBuilderError()
+ data object InvalidData : PermalinkBuilderError()
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt
similarity index 71%
rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt
rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt
index a02fedde4b..0931cf0460 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt
@@ -16,12 +16,9 @@
package io.element.android.libraries.matrix.api.room
-import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
-sealed interface Mention {
- data class User(val userId: UserId) : Mention
- data object AtRoom : Mention
- data class Room(val roomId: RoomId) : Mention
- data class RoomAlias(val roomAlias: RoomAlias?) : Mention
+sealed interface IntentionalMention {
+ data class User(val userId: UserId) : IntentionalMention
+ data object Room : IntentionalMention
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 378ada5b16..b3feb25d5f 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -106,6 +106,11 @@ interface MatrixRoom : Closeable {
*/
suspend fun timelineFocusedOnEvent(eventId: EventId): Result
+ /**
+ * Create a new timeline for the pinned events of the room.
+ */
+ suspend fun pinnedEventsTimeline(): Result
+
fun destroy()
suspend fun subscribeToSync()
@@ -124,9 +129,9 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result
- suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result
+ suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List): Result
- suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result
+ suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List): Result
suspend fun sendImage(
file: File,
@@ -180,6 +185,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserTriggerRoomNotification(userId: UserId): Result
+ suspend fun canUserPinUnpin(userId: UserId): Result
+
suspend fun canUserJoinCall(userId: UserId): Result =
canUserSendState(userId, StateEventType.CALL_MEMBER)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt
index 70da8e7364..c7de03dac6 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -52,4 +53,5 @@ data class MatrixRoomInfo(
val hasRoomCall: Boolean,
val activeRoomCallParticipants: ImmutableList,
val heroes: ImmutableList,
+ val pinnedEventIds: ImmutableList
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt
index fe6a2d9e47..87cc76e199 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt
@@ -17,11 +17,11 @@
package io.element.android.libraries.matrix.api.room.join
import im.vector.app.features.analytics.plan.JoinedRoom
-import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
interface JoinRoom {
suspend operator fun invoke(
- roomId: RoomId,
+ roomIdOrAlias: RoomIdOrAlias,
serverNames: List,
trigger: JoinedRoom.Trigger,
): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
index ef4e6f747e..2abd42f519 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
@@ -65,3 +65,8 @@ suspend fun MatrixRoom.canRedactOwn(): Result = canUserRedactOwn(sessio
* Shortcut for calling [MatrixRoom.canRedactOther] with our own user.
*/
suspend fun MatrixRoom.canRedactOther(): Result = canUserRedactOther(sessionId)
+
+/**
+ * Shortcut for calling [MatrixRoom.canUserPinUnpin] with our own user.
+ */
+suspend fun MatrixRoom.canPinUnpin(): Result = canUserPinUnpin(sessionId)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
index 6841af9721..b932b235fa 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
@@ -28,6 +28,7 @@ data class RoomSummary(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
+ val alternativeAliases: List,
val isDirect: Boolean,
val avatarUrl: String?,
val lastMessage: RoomMessage?,
@@ -44,4 +45,6 @@ data class RoomSummary(
val heroes: List,
) {
val lastMessageTimestamp = lastMessage?.originServerTs
+ val aliases: List
+ get() = listOfNotNull(canonicalAlias) + alternativeAliases
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index 3d8defa252..9585b2521b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
-import io.element.android.libraries.matrix.api.room.Mention
+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.item.event.InReplyTo
import kotlinx.coroutines.flow.Flow
@@ -52,15 +52,24 @@ interface Timeline : AutoCloseable {
fun paginationStatus(direction: PaginationDirection): StateFlow
val timelineItems: Flow>
- suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result
+ suspend fun sendMessage(
+ body: String,
+ htmlBody: String?,
+ intentionalMentions: List,
+ ): Result
- suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List): Result
+ suspend fun editMessage(
+ originalEventId: EventId?,
+ transactionId: TransactionId?,
+ body: String, htmlBody: String?,
+ intentionalMentions: List,
+ ): Result
suspend fun replyMessage(
eventId: EventId,
body: String,
htmlBody: String?,
- mentions: List,
+ intentionalMentions: List,
fromNotification: Boolean = false,
): Result
@@ -169,4 +178,22 @@ interface Timeline : AutoCloseable {
): Result
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
+
+ /**
+ * Adds a new pinned event by sending an updated `m.room.pinned_events`
+ * event containing the new event id.
+ *
+ * Returns `true` if we sent the request, `false` if the event was already
+ * pinned.
+ */
+ suspend fun pinEvent(eventId: EventId): Result
+
+ /**
+ * Adds a new pinned event by sending an updated `m.room.pinned_events`
+ * event without the event id we want to remove.
+ *
+ * Returns `true` if we sent the request, `false` if the event wasn't
+ * pinned
+ */
+ suspend fun unpinEvent(eventId: EventId): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
index fa15f8f096..cc2ad701fe 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt
@@ -26,6 +26,7 @@ data class EventTimelineItem(
val eventId: EventId?,
val transactionId: TransactionId?,
val isEditable: Boolean,
+ val canBeRepliedTo: Boolean,
val isLocal: Boolean,
val isOwn: Boolean,
val isRemote: Boolean,
@@ -38,6 +39,7 @@ data class EventTimelineItem(
val content: EventContent,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
+ val messageShield: MessageShield?,
) {
fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
index b956e51168..8e5f539d81 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
@@ -18,13 +18,27 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UserId
@Immutable
sealed interface LocalEventSendState {
- data object NotSentYet : LocalEventSendState
- sealed class SendingFailed(open val error: String) : LocalEventSendState {
- data class Recoverable(override val error: String) : SendingFailed(error)
- data class Unrecoverable(override val error: String) : SendingFailed(error)
+ data object Sending : LocalEventSendState
+ sealed interface Failed : LocalEventSendState {
+ data class Unknown(val error: String) : Failed
+ data class VerifiedUserHasUnsignedDevice(
+ /**
+ * The unsigned devices belonging to verified users. A map from user ID
+ * to a list of device IDs.
+ */
+ val devices: Map>
+ ) : Failed
+
+ data class VerifiedUserChangedIdentity(
+ /**
+ * The users that were previously verified but are no longer.
+ */
+ val users: List
+ ) : Failed
}
data class Sent(
val eventId: EventId
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt
new file mode 100644
index 0000000000..140df16d66
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.timeline.item.event
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface MessageShield {
+ /** Not enough information available to check the authenticity. */
+ data class AuthenticityNotGuaranteed(val isCritical: Boolean) : MessageShield
+
+ /** The sending device isn't yet known by the Client. */
+ data class UnknownDevice(val isCritical: Boolean) : MessageShield
+
+ /** The sending device hasn't been verified by the sender. */
+ data class UnsignedDevice(val isCritical: Boolean) : MessageShield
+
+ /** The sender hasn't been verified by the Client's user. */
+ data class UnverifiedIdentity(val isCritical: Boolean) : MessageShield
+
+ /** An unencrypted event in an encrypted room. */
+ data class SentInClear(val isCritical: Boolean) : MessageShield
+
+ /** The sender was previously verified but changed their identity. */
+ data class PreviouslyVerified(val isCritical: Boolean) : MessageShield
+}
+
+val MessageShield.isCritical: Boolean
+ get() = when (this) {
+ is MessageShield.AuthenticityNotGuaranteed -> isCritical
+ is MessageShield.UnknownDevice -> isCritical
+ is MessageShield.UnsignedDevice -> isCritical
+ is MessageShield.UnverifiedIdentity -> isCritical
+ is MessageShield.SentInClear -> isCritical
+ is MessageShield.PreviouslyVerified -> isCritical
+ }
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
index d963b71a63..c046f6b2ae 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
@@ -32,7 +32,14 @@ sealed interface OtherState {
data object RoomHistoryVisibility : OtherState
data object RoomJoinRules : OtherState
data class RoomName(val name: String?) : OtherState
- data object RoomPinnedEvents : OtherState
+ data class RoomPinnedEvents(val change: Change) : OtherState {
+ enum class Change {
+ ADDED,
+ REMOVED,
+ CHANGED
+ }
+ }
+
data class RoomUserPowerLevels(val users: Map) : OtherState
data object RoomServerAcl : OtherState
data class RoomThirdPartyInvite(val displayName: String?) : OtherState
diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts
index 523ea2fda1..c74cb6ce07 100644
--- a/libraries/matrix/impl/build.gradle.kts
+++ b/libraries/matrix/impl/build.gradle.kts
@@ -37,12 +37,13 @@ dependencies {
debugImplementation(libs.matrix.sdk)
}
implementation(projects.appconfig)
- implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.di)
+ implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.network)
+ implementation(projects.libraries.preferences.api)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
- implementation(projects.libraries.featureflag.api)
api(projects.libraries.matrix.api)
implementation(libs.dagger)
implementation(projects.libraries.core)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index db86e1a2a5..3aab5bebed 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -24,7 +24,9 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
@@ -41,6 +43,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
@@ -175,7 +178,7 @@ class RustMatrixClient(
val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens()
clientLog.d(
"Removing session data with access token '$anonymizedAccessToken' " +
- "and refresh token '$anonymizedRefreshToken'."
+ "and refresh token '$anonymizedRefreshToken'."
)
if (existingData != null) {
// Set isTokenValid to false
@@ -320,16 +323,23 @@ class RustMatrixClient(
/**
* Wait for the room to be available in the room list.
- * @param roomId the room id to wait for
+ * @param roomIdOrAlias the room id or alias to wait for
* @param timeout the timeout to wait for the room to be available
* @throws TimeoutCancellationException if the room is not available after the timeout
*/
- private suspend fun awaitRoom(roomId: RoomId, timeout: Duration) {
- withTimeout(timeout) {
+ private suspend fun awaitRoom(roomIdOrAlias: RoomIdOrAlias, timeout: Duration): RoomSummary {
+ val predicate: (List) -> Boolean = when (roomIdOrAlias) {
+ is RoomIdOrAlias.Alias -> { roomSummaries: List ->
+ roomSummaries.flatMap { it.aliases }.contains(roomIdOrAlias.roomAlias)
+ }
+ is RoomIdOrAlias.Id -> { roomSummaries: List ->
+ roomSummaries.map { it.roomId }.contains(roomIdOrAlias.roomId)
+ }
+ }
+ return withTimeout(timeout) {
roomListService.allRooms.summaries
- .filter { roomSummaries ->
- roomSummaries.map { it.roomId }.contains(roomId)
- }
+ .filter(predicate)
+ .first()
.first()
}
}
@@ -373,7 +383,7 @@ class RustMatrixClient(
val roomId = RoomId(client.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.
try {
- awaitRoom(roomId, 30.seconds)
+ awaitRoom(roomId.toRoomIdOrAlias(), 30.seconds)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
}
@@ -424,30 +434,29 @@ class RustMatrixClient(
runCatching { client.removeAvatar() }
}
- override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
+ override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
runCatching {
client.joinRoomById(roomId.value).destroy()
try {
- awaitRoom(roomId, 10.seconds)
+ awaitRoom(roomId.toRoomIdOrAlias(), 10.seconds)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
+ null
}
}
}
- override suspend fun joinRoomByIdOrAlias(
- roomId: RoomId,
- serverNames: List,
- ): Result = withContext(sessionDispatcher) {
+ override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) {
runCatching {
client.joinRoomByIdOrAlias(
- roomIdOrAlias = roomId.value,
+ roomIdOrAlias = roomIdOrAlias.identifier,
serverNames = serverNames,
).destroy()
try {
- awaitRoom(roomId, 10.seconds)
+ awaitRoom(roomIdOrAlias, 10.seconds)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
+ null
}
}
}
@@ -478,12 +487,12 @@ class RustMatrixClient(
}
}
- override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result = withContext(sessionDispatcher) {
+ override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) {
runCatching {
- client.getRoomPreviewFromRoomId(
- roomId = roomId.value,
- viaServers = serverNames,
- ).let(RoomPreviewMapper::map)
+ when (roomIdOrAlias) {
+ is RoomIdOrAlias.Alias -> client.getRoomPreviewFromRoomAlias(roomIdOrAlias.roomAlias.value)
+ is RoomIdOrAlias.Id -> client.getRoomPreviewFromRoomId(roomIdOrAlias.roomId.value, serverNames)
+ }.let(RoomPreviewMapper::map)
}
}
@@ -581,7 +590,7 @@ class RustMatrixClient(
var room = getRoom(roomId)
if (room == null) {
emit(Optional.empty())
- awaitRoom(roomId, INFINITE)
+ awaitRoom(roomId.toRoomIdOrAlias(), INFINITE)
room = getRoom(roomId)
}
room?.use {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index 7ddf54a300..a3304dc5bf 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -23,10 +23,12 @@ import io.element.android.libraries.matrix.impl.certificates.UserCertificatesPro
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session
@@ -46,9 +48,18 @@ class RustMatrixClientFactory @Inject constructor(
private val proxyProvider: ProxyProvider,
private val clock: SystemClock,
private val utdTracker: UtdTracker,
+ private val appPreferencesStore: AppPreferencesStore,
) {
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
- val client = getBaseClientBuilder(sessionData.sessionPath, sessionData.passphrase)
+ val client = getBaseClientBuilder(
+ sessionPath = sessionData.sessionPath,
+ passphrase = sessionData.passphrase,
+ slidingSync = if (appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()) {
+ ClientBuilderSlidingSync.Simplified
+ } else {
+ ClientBuilderSlidingSync.Restored
+ },
+ )
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.use { it.build() }
@@ -79,15 +90,24 @@ class RustMatrixClientFactory @Inject constructor(
sessionPath: String,
passphrase: String?,
slidingSyncProxy: String? = null,
+ slidingSync: ClientBuilderSlidingSync,
): ClientBuilder {
return ClientBuilder()
- .sessionPath(sessionPath)
+ // TODO SDK claims it's valid to use the same path for data and cache, but would be better to use different paths
+ .sessionPaths(dataPath = sessionPath, cachePath = sessionPath)
.passphrase(passphrase)
.slidingSyncProxy(slidingSyncProxy)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
+ .run {
+ when (slidingSync) {
+ ClientBuilderSlidingSync.Restored -> this
+ ClientBuilderSlidingSync.Discovered -> requiresSlidingSync()
+ ClientBuilderSlidingSync.Simplified -> simplifiedSlidingSync(true)
+ }
+ }
.run {
// Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep
proxyProvider.provides()?.let { proxy(it) } ?: this
@@ -95,6 +115,17 @@ class RustMatrixClientFactory @Inject constructor(
}
}
+enum class ClientBuilderSlidingSync {
+ // The proxy will be supplied when restoring the Session.
+ Restored,
+
+ // A proxy must be discovered whilst building the session.
+ Discovered,
+
+ // Use Simplified Sliding Sync (discovery isn't a thing yet).
+ Simplified,
+}
+
private fun SessionData.toSession() = Session(
accessToken = accessToken,
refreshToken = refreshToken,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 8c0546fa8b..b447dd584b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
@@ -59,7 +60,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class RustMatrixAuthenticationService @Inject constructor(
- baseDirectory: File,
+ private val baseDirectory: File,
private val coroutineDispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
private val rustMatrixClientFactory: RustMatrixClientFactory,
@@ -69,10 +70,19 @@ class RustMatrixAuthenticationService @Inject constructor(
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
- private val sessionPath = File(baseDirectory, UUID.randomUUID().toString()).absolutePath
+
+ // Need to keep a copy of the current session path to eventually delete it.
+ // Ideally it would be possible to get the sessionPath from the Client to avoid doing this.
+ private var sessionPath: File? = null
private var currentClient: Client? = null
private var currentHomeserver = MutableStateFlow(null)
+ private fun rotateSessionPath(): File {
+ sessionPath?.deleteRecursively()
+ return File(baseDirectory, UUID.randomUUID().toString())
+ .also { sessionPath = it }
+ }
+
override fun loggedInStateFlow(): Flow {
return sessionStore.isLoggedIn()
}
@@ -116,8 +126,9 @@ class RustMatrixAuthenticationService @Inject constructor(
override suspend fun setHomeserver(homeserver: String): Result =
withContext(coroutineDispatchers.io) {
+ val emptySessionPath = rotateSessionPath()
runCatching {
- val client = getBaseClientBuilder()
+ val client = getBaseClientBuilder(emptySessionPath)
.serverNameOrHomeserverUrl(homeserver)
.build()
currentClient = client
@@ -134,13 +145,14 @@ class RustMatrixAuthenticationService @Inject constructor(
withContext(coroutineDispatchers.io) {
runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
+ val currentSessionPath = sessionPath ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
- sessionPath = sessionPath,
+ sessionPath = currentSessionPath.absolutePath,
)
clear()
sessionStore.storeData(sessionData)
@@ -184,13 +196,14 @@ class RustMatrixAuthenticationService @Inject constructor(
return withContext(coroutineDispatchers.io) {
runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
+ val currentSessionPath = sessionPath ?: error("You need to call `setHomeserver()` first")
val urlForOidcLogin = pendingOidcAuthorizationData ?: error("You need to call `getOidcUrl()` first")
client.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase,
- sessionPath = sessionPath,
+ sessionPath = currentSessionPath.absolutePath,
)
clear()
pendingOidcAuthorizationData?.close()
@@ -205,11 +218,13 @@ class RustMatrixAuthenticationService @Inject constructor(
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
withContext(coroutineDispatchers.io) {
+ val emptySessionPath = rotateSessionPath()
runCatching {
val client = rustMatrixClientFactory.getBaseClientBuilder(
- sessionPath = sessionPath,
+ sessionPath = emptySessionPath.absolutePath,
passphrase = pendingPassphrase,
slidingSyncProxy = AuthenticationConfig.SLIDING_SYNC_PROXY_URL,
+ slidingSync = ClientBuilderSlidingSync.Discovered,
)
.buildWithQrCode(
qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData,
@@ -227,7 +242,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true,
loginType = LoginType.QR,
passphrase = pendingPassphrase,
- sessionPath = sessionPath,
+ sessionPath = emptySessionPath.absolutePath,
)
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
@@ -244,15 +259,17 @@ class RustMatrixAuthenticationService @Inject constructor(
}
Timber.e(throwable, "Failed to login with QR code")
}
- }
+ }
- private fun getBaseClientBuilder() = rustMatrixClientFactory
+ private fun getBaseClientBuilder(
+ sessionPath: File,
+ ) = rustMatrixClientFactory
.getBaseClientBuilder(
- sessionPath = sessionPath,
+ sessionPath = sessionPath.absolutePath,
passphrase = pendingPassphrase,
slidingSyncProxy = AuthenticationConfig.SLIDING_SYNC_PROXY_URL,
+ slidingSync = ClientBuilderSlidingSync.Discovered,
)
- .requiresSlidingSync()
private fun clear() {
currentClient?.close()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
index 68ab4a611e..ae681b2771 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
@@ -18,10 +18,12 @@ package io.element.android.libraries.matrix.impl.encryption
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
+import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
@@ -54,6 +56,7 @@ internal class RustEncryptionService(
private val dispatchers: CoroutineDispatchers,
) : EncryptionService {
private val service: Encryption = client.encryption()
+ private val sessionId = SessionId(client.session().userId)
private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper()
private val backupUploadStateMapper = BackupUploadStateMapper()
@@ -198,4 +201,12 @@ internal class RustEncryptionService(
override suspend fun deviceEd25519(): String? {
return service.ed25519Key()
}
+
+ override suspend fun startIdentityReset(): Result {
+ return runCatching {
+ service.resetIdentity()?.let { handle ->
+ RustIdentityResetHandleFactory.create(sessionId, handle)
+ }?.getOrNull()
+ }
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt
new file mode 100644
index 0000000000..c4c20eb7d6
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.encryption
+
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
+import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
+import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
+import org.matrix.rustcomponents.sdk.AuthData
+import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
+import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType
+
+object RustIdentityResetHandleFactory {
+ fun create(
+ userId: UserId,
+ identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle
+ ): Result {
+ return runCatching {
+ when (val authType = identityResetHandle.authType()) {
+ is CrossSigningResetAuthType.Oidc -> RustOidcIdentityResetHandle(identityResetHandle, authType.info.approvalUrl)
+ // User interactive authentication (user + password)
+ CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
+ }
+ }
+ }
+}
+
+class RustPasswordIdentityResetHandle(
+ private val userId: UserId,
+ private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
+) : IdentityPasswordResetHandle {
+ override suspend fun resetPassword(password: String): Result {
+ return runCatching { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) }
+ }
+
+ override suspend fun cancel() {
+ identityResetHandle.cancelAndDestroy()
+ }
+}
+
+class RustOidcIdentityResetHandle(
+ private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
+ override val url: String,
+) : IdentityOidcResetHandle {
+ override suspend fun resetOidc(): Result {
+ return runCatching { identityResetHandle.reset(null) }
+ }
+
+ override suspend fun cancel() {
+ identityResetHandle.cancelAndDestroy()
+ }
+}
+
+private suspend fun org.matrix.rustcomponents.sdk.IdentityResetHandle.cancelAndDestroy() {
+ cancel()
+ destroy()
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
index 4591a2ef52..fa0b6365f3 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
@@ -94,7 +94,7 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
is MessageLikeEventContent.RoomMessage -> {
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
}
- MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction
+ is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(redactedEventId = redactedEventId, reason = reason)
MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker
is MessageLikeEventContent.Poll -> NotificationContent.MessageLike.Poll(senderId, question)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
index 30a458b28f..e3a327300f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
@@ -31,7 +31,7 @@ import javax.inject.Inject
class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result {
if (!MatrixPatterns.isUserId(userId.value)) {
- return Result.failure(PermalinkBuilderError.InvalidUserId)
+ return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatching {
matrixToUserPermalink(userId.value)
@@ -40,7 +40,7 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result {
if (!MatrixPatterns.isRoomAlias(roomAlias.value)) {
- return Result.failure(PermalinkBuilderError.InvalidRoomAlias)
+ return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatching {
matrixToRoomAliasPermalink(roomAlias.value)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt
index 6a87b02a2b..b53b7629fd 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.room
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -55,10 +56,11 @@ class MatrixRoomInfoMapper {
userPowerLevels = mapPowerLevels(it.userPowerLevels),
highlightCount = it.highlightCount.toLong(),
notificationCount = it.notificationCount.toLong(),
- userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
+ userDefinedNotificationMode = it.cachedUserDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall,
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(),
- heroes = it.elementHeroes().toImmutableList()
+ heroes = it.elementHeroes().toImmutableList(),
+ pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(),
)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt
index 795ac2e003..85c63aebc1 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt
@@ -16,11 +16,11 @@
package io.element.android.libraries.matrix.impl.room
-import io.element.android.libraries.matrix.api.room.Mention
+import io.element.android.libraries.matrix.api.room.IntentionalMention
import org.matrix.rustcomponents.sdk.Mentions
-fun List.map(): Mentions {
- val hasAtRoom = any { it is Mention.AtRoom }
- val userIds = filterIsInstance().map { it.userId.value }
- return Mentions(userIds, hasAtRoom)
+fun List.map(): Mentions {
+ val hasRoom = any { it is IntentionalMention.Room }
+ val userIds = filterIsInstance().map { it.userId.value }
+ return Mentions(userIds, hasRoom)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
index a52d3a87ed..b149f88006 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
@@ -51,23 +51,15 @@ class RoomSyncSubscriber(
includeHeroes = false,
)
- suspend fun subscribe(roomId: RoomId) = mutex.withLock {
- withContext(dispatchers.io) {
- try {
- subscribeToRoom(roomId)
- } catch (exception: Exception) {
- Timber.e("Failed to subscribe to room $roomId")
- }
- }
- }
-
- suspend fun batchSubscribe(roomIds: List) = mutex.withLock {
- withContext(dispatchers.io) {
- for (roomId in roomIds) {
+ suspend fun subscribe(roomId: RoomId) {
+ mutex.withLock {
+ withContext(dispatchers.io) {
try {
- subscribeToRoom(roomId)
- } catch (cancellationException: CancellationException) {
- throw cancellationException
+ if (!isSubscribedTo(roomId)) {
+ Timber.d("Subscribing to room $roomId}")
+ roomListService.subscribeToRooms(listOf(roomId.value), settings)
+ }
+ subscribedRoomIds.add(roomId)
} catch (exception: Exception) {
Timber.e("Failed to subscribe to room $roomId")
}
@@ -75,14 +67,21 @@ class RoomSyncSubscriber(
}
}
- private fun subscribeToRoom(roomId: RoomId) {
- if (!isSubscribedTo(roomId)) {
- Timber.d("Subscribing to room $roomId}")
- roomListService.room(roomId.value).use { roomListItem ->
- roomListItem.subscribe(settings)
+ suspend fun batchSubscribe(roomIds: List) = mutex.withLock {
+ withContext(dispatchers.io) {
+ try {
+ val roomIdsToSubscribeTo = roomIds.filterNot { isSubscribedTo(it) }
+ if (roomIdsToSubscribeTo.isNotEmpty()) {
+ Timber.d("Subscribing to rooms: $roomIds")
+ roomListService.subscribeToRooms(roomIdsToSubscribeTo.map { it.value }, settings)
+ subscribedRoomIds.addAll(roomIds)
+ }
+ } catch (cancellationException: CancellationException) {
+ throw cancellationException
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to subscribe to rooms: $roomIds")
}
}
- subscribedRoomIds.add(roomId)
}
fun isSubscribedTo(roomId: RoomId): Boolean {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index b594bba5e4..20dceeeb87 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -33,11 +33,11 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
-import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
@@ -192,6 +192,21 @@ class RustMatrixRoom(
}
}
+ override suspend fun pinnedEventsTimeline(): Result {
+ return runCatching {
+ innerRoom.pinnedEventsTimeline(
+ internalIdPrefix = "pinned_events",
+ maxEventsToLoad = 100u,
+ ).let { inner ->
+ createTimeline(inner, isLive = false)
+ }
+ }.onFailure {
+ if (it is CancellationException) {
+ throw it
+ }
+ }
+ }
+
override fun destroy() {
roomCoroutineScope.cancel()
liveTimeline.close()
@@ -325,16 +340,21 @@ class RustMatrixRoom(
}
}
- override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) {
+ override suspend fun editMessage(
+ eventId: EventId,
+ body: String,
+ htmlBody: String?,
+ intentionalMentions: List
+ ): Result = withContext(roomDispatcher) {
runCatching {
- MessageEventContent.from(body, htmlBody, mentions).use { newContent ->
+ MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent ->
innerRoom.edit(eventId.value, newContent)
}
}
}
- override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result {
- return liveTimeline.sendMessage(body, htmlBody, mentions)
+ override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List): Result {
+ return liveTimeline.sendMessage(body, htmlBody, intentionalMentions)
}
override suspend fun leave(): Result = withContext(roomDispatcher) {
@@ -403,6 +423,12 @@ class RustMatrixRoom(
}
}
+ override suspend fun canUserPinUnpin(userId: UserId): Result {
+ return runCatching {
+ innerRoom.canUserPinUnpin(userId.value)
+ }
+ }
+
override suspend fun sendImage(
file: File,
thumbnailFile: File?,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt
index d2ce4e61d5..063facbc96 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt
@@ -20,8 +20,9 @@ import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
-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.room.join.JoinRoom
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@@ -32,18 +33,30 @@ class DefaultJoinRoom @Inject constructor(
private val analyticsService: AnalyticsService,
) : JoinRoom {
override suspend fun invoke(
- roomId: RoomId,
+ roomIdOrAlias: RoomIdOrAlias,
serverNames: List,
- trigger: JoinedRoom.Trigger,
+ trigger: JoinedRoom.Trigger
): Result {
- return if (serverNames.isEmpty()) {
- client.joinRoom(roomId)
- } else {
- client.joinRoomByIdOrAlias(roomId, serverNames)
- }.onSuccess {
- client.getRoom(roomId)?.use { room ->
- analyticsService.capture(room.toAnalyticsJoinedRoom(trigger))
+ return when (roomIdOrAlias) {
+ is RoomIdOrAlias.Id -> {
+ if (serverNames.isEmpty()) {
+ client.joinRoom(roomIdOrAlias.roomId)
+ } else {
+ client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames)
+ }
}
+ is RoomIdOrAlias.Alias -> {
+ client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames = emptyList())
+ }
+ }.onSuccess { roomSummary ->
+ client.captureJoinedRoomAnalytics(roomSummary, trigger)
+ }.map { }
+ }
+
+ private suspend fun MatrixClient.captureJoinedRoomAnalytics(roomSummary: RoomSummary?, trigger: JoinedRoom.Trigger) {
+ if (roomSummary == null) return
+ getRoom(roomSummary.roomId)?.use { room ->
+ analyticsService.capture(room.toAnalyticsJoinedRoom(trigger))
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
index a833734a5c..8f0863ec2c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
@@ -38,6 +38,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
roomId = RoomId(roomInfo.id),
name = roomInfo.displayName,
canonicalAlias = roomInfo.canonicalAlias?.let(::RoomAlias),
+ alternativeAliases = roomInfo.alternativeAliases.map(::RoomAlias),
isDirect = roomInfo.isDirect,
avatarUrl = roomInfo.avatarUrl,
numUnreadMentions = roomInfo.numUnreadMentions.toInt(),
@@ -46,7 +47,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
isMarkedUnread = roomInfo.isMarkedUnread,
lastMessage = latestRoomMessage,
inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
- userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
+ userDefinedNotificationMode = roomInfo.cachedUserDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall,
isDm = isDm(isDirect = roomInfo.isDirect, activeMembersCount = roomInfo.activeMembersCount.toInt()),
isFavorite = roomInfo.isFavourite,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index b57efc5603..8c774d2d29 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
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.MatrixRoom
-import io.element.android.libraries.matrix.api.room.Mention
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
@@ -263,8 +263,12 @@ class RustTimeline(
}
}
- override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = withContext(dispatcher) {
- MessageEventContent.from(body, htmlBody, mentions).use { content ->
+ override suspend fun sendMessage(
+ body: String,
+ htmlBody: String?,
+ intentionalMentions: List,
+ ): Result = withContext(dispatcher) {
+ MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
runCatching {
inner.send(content)
}
@@ -284,13 +288,13 @@ class RustTimeline(
transactionId: TransactionId?,
body: String,
htmlBody: String?,
- mentions: List,
+ intentionalMentions: List,
): Result =
withContext(dispatcher) {
runCatching {
getEventTimelineItem(originalEventId, transactionId).use { item ->
inner.edit(
- newContent = MessageEventContent.from(body, htmlBody, mentions),
+ newContent = MessageEventContent.from(body, htmlBody, intentionalMentions),
item = item,
)
}
@@ -301,11 +305,11 @@ class RustTimeline(
eventId: EventId,
body: String,
htmlBody: String?,
- mentions: List,
+ intentionalMentions: List,
fromNotification: Boolean,
): Result = withContext(dispatcher) {
runCatching {
- val msg = MessageEventContent.from(body, htmlBody, mentions)
+ val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
inner.sendReply(msg, eventId.value)
}
}
@@ -525,6 +529,18 @@ class RustTimeline(
}
}
+ override suspend fun pinEvent(eventId: EventId): Result = withContext(dispatcher) {
+ runCatching {
+ inner.pinEvent(eventId = eventId.value)
+ }
+ }
+
+ override suspend fun unpinEvent(eventId: EventId): Result = withContext(dispatcher) {
+ runCatching {
+ inner.unpinEvent(eventId = eventId.value)
+ }
+ }
+
private suspend fun fetchDetailsForEvent(eventId: EventId): Result {
return runCatching {
inner.fetchDetailsForEvent(eventId.value)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
index dd0bdabd7f..d51e4bf3c8 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
@@ -31,6 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction
+import org.matrix.rustcomponents.sdk.ShieldState
+import uniffi.matrix_sdk_common.ShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@@ -44,6 +47,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
eventId = it.eventId()?.let(::EventId),
transactionId = it.transactionId()?.let(::TransactionId),
isEditable = it.isEditable(),
+ canBeRepliedTo = it.canBeRepliedTo(),
isLocal = it.isLocal(),
isOwn = it.isOwn(),
isRemote = it.isRemote(),
@@ -55,7 +59,8 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
timestamp = it.timestamp().toLong(),
content = contentMapper.map(it.content()),
debugInfo = it.debugInfo().map(),
- origin = it.origin()?.map()
+ origin = it.origin()?.map(),
+ messageShield = it.getShield(false)?.map(),
)
}
}
@@ -76,15 +81,23 @@ fun RustProfileDetails.map(): ProfileTimelineDetails {
fun RustEventSendState?.map(): LocalEventSendState? {
return when (this) {
null -> null
- RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet
+ RustEventSendState.NotSentYet -> LocalEventSendState.Sending
is RustEventSendState.SendingFailed -> {
- if (this.isRecoverable) {
- LocalEventSendState.SendingFailed.Recoverable(this.error)
+ if (isRecoverable) {
+ LocalEventSendState.Sending
} else {
- LocalEventSendState.SendingFailed.Unrecoverable(this.error)
+ LocalEventSendState.Failed.Unknown(error)
}
}
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
+ is RustEventSendState.VerifiedUserChangedIdentity -> {
+ LocalEventSendState.Failed.VerifiedUserChangedIdentity(users.map { UserId(it) })
+ }
+ is RustEventSendState.VerifiedUserHasUnsignedDevice -> {
+ LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice(
+ devices = devices.mapKeys { UserId(it.key) }
+ )
+ }
}
}
@@ -128,3 +141,25 @@ private fun RustEventItemOrigin.map(): TimelineItemEventOrigin {
RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION
}
}
+
+private fun ShieldState?.map(): MessageShield? {
+ this ?: return null
+ val shieldStateCode = when (this) {
+ is ShieldState.Grey -> code
+ is ShieldState.Red -> code
+ ShieldState.None -> null
+ } ?: return null
+ val isCritical = when (this) {
+ ShieldState.None,
+ is ShieldState.Grey -> false
+ is ShieldState.Red -> true
+ }
+ return when (shieldStateCode) {
+ ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
+ ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
+ ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
+ ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
+ ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
+ ShieldStateCode.PREVIOUSLY_VERIFIED -> MessageShield.PreviouslyVerified(isCritical)
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index 6b0da71eee..6e44579d7c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -40,6 +40,7 @@ import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import org.matrix.rustcomponents.sdk.use
+import uniffi.matrix_sdk_ui.RoomPinnedEventsChange
import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage
import org.matrix.rustcomponents.sdk.MembershipChange as RustMembershipChange
import org.matrix.rustcomponents.sdk.OtherState as RustOtherState
@@ -176,7 +177,7 @@ private fun RustOtherState.map(): OtherState {
RustOtherState.RoomHistoryVisibility -> OtherState.RoomHistoryVisibility
RustOtherState.RoomJoinRules -> OtherState.RoomJoinRules
is RustOtherState.RoomName -> OtherState.RoomName(name)
- RustOtherState.RoomPinnedEvents -> OtherState.RoomPinnedEvents
+ is RustOtherState.RoomPinnedEvents -> OtherState.RoomPinnedEvents(change.map())
is RustOtherState.RoomPowerLevels -> OtherState.RoomUserPowerLevels(users)
RustOtherState.RoomServerAcl -> OtherState.RoomServerAcl
is RustOtherState.RoomThirdPartyInvite -> OtherState.RoomThirdPartyInvite(displayName)
@@ -187,6 +188,14 @@ private fun RustOtherState.map(): OtherState {
}
}
+private fun RoomPinnedEventsChange.map(): OtherState.RoomPinnedEvents.Change {
+ return when (this) {
+ RoomPinnedEventsChange.ADDED -> OtherState.RoomPinnedEvents.Change.ADDED
+ RoomPinnedEventsChange.REMOVED -> OtherState.RoomPinnedEvents.Change.REMOVED
+ RoomPinnedEventsChange.CHANGED -> OtherState.RoomPinnedEvents.Change.CHANGED
+ }
+}
+
private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data {
return when (this) {
is RustEncryptedMessage.MegolmV1AesSha2 -> UnableToDecryptContent.Data.MegolmV1AesSha2(sessionId, cause.map())
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
index e1728bb528..70de27a30d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
@@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.impl.util
-import io.element.android.libraries.matrix.api.room.Mention
+import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@@ -26,11 +26,11 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
- fun from(body: String, htmlBody: String?, mentions: List): RoomMessageEventContentWithoutRelation {
+ fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
- }.withMentions(mentions.map())
+ }.withMentions(intentionalMentions.map())
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt
index 6296f219de..9941b6b1ed 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt
@@ -20,11 +20,15 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.libraries.matrix.api.MatrixClient
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom
+import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SERVER_LIST
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -33,9 +37,10 @@ import org.junit.Test
class DefaultJoinRoomTest {
@Test
- fun `when there is no server names, the classic join room API is used`() = runTest {
- val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(Unit) }
- val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomId, _: List -> Result.success(Unit) }
+ fun `when using roomId and there is no server names, the classic join room API is used`() = runTest {
+ val roomSummary = aRoomSummaryFilled()
+ val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
+ val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomSummary) }
val roomResult = FakeMatrixRoom()
val aTrigger = JoinedRoom.Trigger.MobilePermalink
val client: MatrixClient = FakeMatrixClient().also {
@@ -51,7 +56,7 @@ class DefaultJoinRoomTest {
client = client,
analyticsService = analyticsService,
)
- sut.invoke(A_ROOM_ID, emptyList(), aTrigger)
+ sut.invoke(A_ROOM_ID.toRoomIdOrAlias(), emptyList(), aTrigger)
joinRoomByIdOrAliasLambda
.assertions()
.isNeverCalled()
@@ -67,9 +72,10 @@ class DefaultJoinRoomTest {
}
@Test
- fun `when server names are available, joinRoomByIdOrAlias API is used`() = runTest {
- val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(Unit) }
- val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomId, _: List -> Result.success(Unit) }
+ fun `when using roomId and server names are available, joinRoomByIdOrAlias API is used`() = runTest {
+ val roomSummary = aRoomSummaryFilled()
+ val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
+ val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomSummary) }
val roomResult = FakeMatrixRoom()
val aTrigger = JoinedRoom.Trigger.MobilePermalink
val client: MatrixClient = FakeMatrixClient().also {
@@ -85,12 +91,12 @@ class DefaultJoinRoomTest {
client = client,
analyticsService = analyticsService,
)
- sut.invoke(A_ROOM_ID, A_SERVER_LIST, aTrigger)
+ sut.invoke(A_ROOM_ID.toRoomIdOrAlias(), A_SERVER_LIST, aTrigger)
joinRoomByIdOrAliasLambda
.assertions()
.isCalledOnce()
.with(
- value(A_ROOM_ID),
+ value(A_ROOM_ID.toRoomIdOrAlias()),
value(A_SERVER_LIST)
)
joinRoomLambda
@@ -100,4 +106,40 @@ class DefaultJoinRoomTest {
roomResult.toAnalyticsJoinedRoom(aTrigger)
)
}
+
+ @Test
+ fun `when using roomAlias, joinRoomByIdOrAlias API is used`() = runTest {
+ val roomSummary = aRoomSummaryFilled()
+ val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
+ val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomSummary) }
+ val roomResult = FakeMatrixRoom()
+ val aTrigger = JoinedRoom.Trigger.MobilePermalink
+ val client: MatrixClient = FakeMatrixClient().also {
+ it.joinRoomLambda = joinRoomLambda
+ it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda
+ it.givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = roomResult
+ )
+ }
+ val analyticsService = FakeAnalyticsService()
+ val sut = DefaultJoinRoom(
+ client = client,
+ analyticsService = analyticsService,
+ )
+ sut.invoke(A_ROOM_ALIAS.toRoomIdOrAlias(), A_SERVER_LIST, aTrigger)
+ joinRoomByIdOrAliasLambda
+ .assertions()
+ .isCalledOnce()
+ .with(
+ value(A_ROOM_ALIAS.toRoomIdOrAlias()),
+ value(emptyList())
+ )
+ joinRoomLambda
+ .assertions()
+ .isNeverCalled()
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ roomResult.toAnalyticsJoinedRoom(aTrigger)
+ )
+ }
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
index f2e2f07ad6..5458c2dd1a 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
@@ -189,6 +189,8 @@ class RoomSummaryListProcessorTest {
override fun syncIndicator(delayBeforeShowingInMs: UInt, delayBeforeHidingInMs: UInt, listener: RoomListServiceSyncIndicatorListener): TaskHandle {
return TaskHandle(Pointer.NULL)
}
+
+ override fun subscribeToRooms(roomIds: List, settings: RoomSubscription?) = Unit
}
}
@@ -221,6 +223,7 @@ private fun aRustRoomInfo(
numUnreadMessages: ULong = 0uL,
numUnreadNotifications: ULong = 0uL,
numUnreadMentions: ULong = 0uL,
+ pinnedEventIds: List = listOf(),
) = RoomInfo(
id = id,
displayName = displayName,
@@ -243,13 +246,14 @@ private fun aRustRoomInfo(
userPowerLevels = userPowerLevels,
highlightCount = highlightCount,
notificationCount = notificationCount,
- userDefinedNotificationMode = userDefinedNotificationMode,
+ cachedUserDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
activeRoomCallParticipants = activeRoomCallParticipants,
isMarkedUnread = isMarkedUnread,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
- numUnreadMentions = numUnreadMentions
+ numUnreadMentions = numUnreadMentions,
+ pinnedEventIds = pinnedEventIds,
)
class FakeRoomListItem(
@@ -268,6 +272,4 @@ class FakeRoomListItem(
override suspend fun latestEvent(): EventTimelineItem? {
return latestEvent
}
-
- override fun subscribe(settings: RoomSubscription?) = Unit
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 1b6223279c..1fb8878488 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
@@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -80,7 +82,7 @@ class FakeMatrixClient(
private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
private val accountManagementUrlString: Result = Result.success(null),
private val resolveRoomAliasResult: (RoomAlias) -> Result = { Result.success(ResolvedRoomAlias(A_ROOM_ID, emptyList())) },
- private val getRoomPreviewFromRoomIdResult: (RoomId, List) -> Result = { _, _ -> Result.failure(AN_EXCEPTION) },
+ private val getRoomPreviewResult: (RoomIdOrAlias, List) -> Result = { _, _ -> Result.failure(AN_EXCEPTION) },
private val clearCacheLambda: () -> Unit = { lambdaError() },
private val userIdServerNameLambda: () -> String = { lambdaError() },
private val getUrlLambda: (String) -> Result = { lambdaError() },
@@ -108,11 +110,11 @@ class FakeMatrixClient(
private var setDisplayNameResult: Result = Result.success(Unit)
private var uploadAvatarResult: Result = Result.success(Unit)
private var removeAvatarResult: Result = Result.success(Unit)
- var joinRoomLambda: (RoomId) -> Result = {
- Result.success(Unit)
+ var joinRoomLambda: (RoomId) -> Result = {
+ Result.success(null)
}
- var joinRoomByIdOrAliasLambda: (RoomId, List) -> Result = { _, _ ->
- Result.success(Unit)
+ var joinRoomByIdOrAliasLambda: (RoomIdOrAlias, List) -> Result = { _, _ ->
+ Result.success(null)
}
var knockRoomLambda: (RoomId) -> Result = {
Result.success(Unit)
@@ -207,10 +209,10 @@ class FakeMatrixClient(
return removeAvatarResult
}
- override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId)
+ override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId)
- override suspend fun joinRoomByIdOrAlias(roomId: RoomId, serverNames: List): Result {
- return joinRoomByIdOrAliasLambda(roomId, serverNames)
+ override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result {
+ return joinRoomByIdOrAliasLambda(roomIdOrAlias, serverNames)
}
override suspend fun knockRoom(roomId: RoomId): Result = knockRoomLambda(roomId)
@@ -297,8 +299,8 @@ class FakeMatrixClient(
resolveRoomAliasResult(roomAlias)
}
- override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List) = simulateLongTask {
- getRoomPreviewFromRoomIdResult(roomId, serverNames)
+ override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = simulateLongTask {
+ getRoomPreviewResult(roomIdOrAlias, serverNames)
}
override suspend fun getRecentlyVisitedRooms(): Result> {
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt
index b864c69b0b..82ab5e251f 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt
@@ -20,13 +20,17 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
-class FakeEncryptionService : EncryptionService {
+class FakeEncryptionService(
+ var startIdentityResetLambda: () -> Result = { lambdaError() },
+) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN)
override val recoveryStateStateFlow: MutableStateFlow = MutableStateFlow(RecoveryState.UNKNOWN)
@@ -118,6 +122,10 @@ class FakeEncryptionService : EncryptionService {
enableRecoveryProgressStateFlow.emit(state)
}
+ override suspend fun startIdentityReset(): Result {
+ return startIdentityResetLambda()
+ }
+
companion object {
const val FAKE_RECOVERY_KEY = "fake"
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt
new file mode 100644
index 0000000000..69087163d2
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.encryption
+
+import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
+import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
+
+class FakeIdentityOidcResetHandle(
+ override val url: String = "",
+ var resetOidcLambda: () -> Result = { error("Not implemented") },
+ var cancelLambda: () -> Unit = { error("Not implemented") },
+) : IdentityOidcResetHandle {
+ override suspend fun resetOidc(): Result {
+ return resetOidcLambda()
+ }
+
+ override suspend fun cancel() {
+ cancelLambda()
+ }
+}
+
+class FakeIdentityPasswordResetHandle(
+ var resetPasswordLambda: (String) -> Result = { _ -> error("Not implemented") },
+ var cancelLambda: () -> Unit = { error("Not implemented") },
+) : IdentityPasswordResetHandle {
+ override suspend fun resetPassword(password: String): Result {
+ return resetPasswordLambda(password)
+ }
+
+ override suspend fun cancel() {
+ cancelLambda()
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
index 3510a362ec..5b18c3b175 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
@@ -19,10 +19,11 @@ package io.element.android.libraries.matrix.test.permalink
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkBuilder(
- private val permalinkForUserLambda: (UserId) -> Result = { Result.failure(Exception("Not implemented")) },
- private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { Result.failure(Exception("Not implemented")) },
+ private val permalinkForUserLambda: (UserId) -> Result = { lambdaError() },
+ private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { lambdaError() },
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result {
return permalinkForUserLambda(userId)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index cfd0267516..af1ad33bdc 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -31,11 +31,11 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
-import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@@ -105,7 +105,8 @@ class FakeMatrixRoom(
private val setTopicResult: (String) -> Result = { lambdaError() },
private val updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() },
private val removeAvatarResult: () -> Result = { lambdaError() },
- private val sendMessageResult: (String, String?, List) -> Result = { _, _, _ -> lambdaError() },
+ private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() },
+ private val sendMessageResult: (String, String?, List) -> Result = { _, _, _ -> lambdaError() },
private val updateUserRoleResult: () -> Result = { lambdaError() },
private val toggleReactionResult: (String, EventId) -> Result = { _, _ -> lambdaError() },
private val retrySendMessageResult: (TransactionId) -> Result = { lambdaError() },
@@ -125,6 +126,7 @@ class FakeMatrixRoom(
private val getWidgetDriverResult: (MatrixWidgetSettings) -> Result = { lambdaError() },
private val canUserTriggerRoomNotificationResult: (UserId) -> Result = { lambdaError() },
private val canUserJoinCallResult: (UserId) -> Result = { lambdaError() },
+ private val canUserPinUnpinResult: (UserId) -> Result = { lambdaError() },
private val setIsFavoriteResult: (Boolean) -> Result = { lambdaError() },
private val powerLevelsResult: () -> Result = { lambdaError() },
private val updatePowerLevelsResult: () -> Result = { lambdaError() },
@@ -134,10 +136,12 @@ class FakeMatrixRoom(
private val updateMembersResult: () -> Unit = { lambdaError() },
private val getMembersResult: (Int) -> Result> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result = { lambdaError() },
+ private val pinnedEventsTimelineResult: () -> Result = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result = { Result.success(null) },
private val clearComposerDraftLambda: () -> Result = { Result.success(Unit) },
+ private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
) : MatrixRoom {
private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow = _roomInfoFlow
@@ -180,7 +184,13 @@ class FakeMatrixRoom(
timelineFocusedOnEventResult(eventId)
}
- override suspend fun subscribeToSync() = Unit
+ override suspend fun pinnedEventsTimeline(): Result = simulateLongTask {
+ pinnedEventsTimelineResult()
+ }
+
+ override suspend fun subscribeToSync() {
+ subscribeToSyncLambda()
+ }
override suspend fun powerLevels(): Result {
return powerLevelsResult()
@@ -212,13 +222,12 @@ class FakeMatrixRoom(
return updateUserRoleResult()
}
- var editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }
- override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result {
- return editMessageLambda(eventId, body, htmlBody, mentions)
+ override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List) = simulateLongTask {
+ editMessageLambda(eventId, body, htmlBody, intentionalMentions)
}
- override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List) = simulateLongTask {
- sendMessageResult(body, htmlBody, mentions)
+ override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List) = simulateLongTask {
+ sendMessageResult(body, htmlBody, intentionalMentions)
}
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result {
@@ -289,6 +298,10 @@ class FakeMatrixRoom(
return canUserJoinCallResult(userId)
}
+ override suspend fun canUserPinUnpin(userId: UserId): Result {
+ return canUserPinUnpinResult(userId)
+ }
+
override suspend fun sendImage(
file: File,
thumbnailFile: File?,
@@ -517,6 +530,7 @@ fun aRoomInfo(
userPowerLevels: ImmutableMap = persistentMapOf(),
activeRoomCallParticipants: List