diff --git a/.editorconfig b/.editorconfig
index d2f28922d9..a41ddcad7f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -35,6 +35,7 @@ ktlint_standard_class-signature = disabled
ktlint_standard_when-entry-bracing = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
ktlint_standard_mixed-condition-operators = disabled
+ktlint_standard_no-unused-imports = enabled
[*.java]
ij_java_align_consecutive_assignments = false
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ef6b3773cd..aa62b83e36 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -46,6 +46,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -53,7 +54,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-debug
path: |
@@ -61,7 +62,7 @@ jobs:
app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
- name: Upload x86_64 APK for Maestro
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-apk-maestro
path: |
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 81e17d6189..1d416ffe42 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -54,6 +54,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -61,7 +62,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-enterprise-debug
path: |
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index 460fdcf6c2..4fd42534b7 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -44,7 +44,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-apk-maestro
path: |
@@ -69,7 +69,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
- name: Download APK artifact from previous job
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
name: elementx-apk-maestro
- name: Enable KVM group perms
@@ -102,7 +102,7 @@ jobs:
script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
- name: Upload test results
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: test-results
path: |
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 71a2d0a595..0ea049b2f0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -30,6 +30,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 46818a1749..b7119c7036 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -42,7 +42,7 @@ jobs:
- name: ✅ Upload kover report
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: kover-results
path: |
@@ -74,7 +74,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index cc4b9db7e1..2084c7648a 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -97,7 +97,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: konsist-report
path: |
@@ -174,7 +174,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: linting-report
path: |
@@ -214,7 +214,7 @@ jobs:
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: detekt-report
path: |
@@ -254,7 +254,7 @@ jobs:
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: ktlint-report
path: |
@@ -317,7 +317,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Download reports from previous jobs
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
- name: Prepare Danger
if: always()
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 919897deed..439b97ba8c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,13 +32,14 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-app-gplay-bundle-unsigned
path: |
@@ -74,7 +75,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-enterprise-app-gplay-bundle-unsigned
path: |
@@ -102,7 +103,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-app-fdroid-apks-unsigned
path: |
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 073c075502..2086eab3aa 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
+ uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index ca42b8fbe6..2f5f22a2d5 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script
run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
+ uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4965530b5a..aa11a433f4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -61,7 +61,7 @@ jobs:
- name: 🚫 Upload kover failed coverage reports
if: failure()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: kover-error-report
path: |
@@ -73,7 +73,7 @@ jobs:
- name: 🚫 Upload test results on error
if: failure()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: tests-and-screenshot-tests-results
path: |
@@ -83,7 +83,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 3efb2d8dd4..dbbf81b44b 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 01a83c0ccf..47ea7ec332 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,58 @@
+Changes in Element X v25.12.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Room list: enable latest event sorter. by @bmarty in https://github.com/element-hq/element-x-android/pull/5825
+* Add room list indicators about last message by @bmarty in https://github.com/element-hq/element-x-android/pull/5824
+### 🙌 Improvements
+* Change : improve room and space member list by @ganfra in https://github.com/element-hq/element-x-android/pull/5806
+* Change : security and privacy rework by @ganfra in https://github.com/element-hq/element-x-android/pull/5816
+### 🐛 Bugfixes
+* Ensure confirmation dialog is displayed when an admin add other admin to a room by @bmarty in https://github.com/element-hq/element-x-android/pull/5786
+* Edit user profile cancel confirmation by @bmarty in https://github.com/element-hq/element-x-android/pull/5788
+* Fix editing owner by @bmarty in https://github.com/element-hq/element-x-android/pull/5807
+* Uris should take precedence in plain text intents by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5785
+* Fix long voice recording by @bmarty in https://github.com/element-hq/element-x-android/pull/5821
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5792
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5830
+### 🧱 Build
+* Use regex to check forbidden terms by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5784
+* Update Gradle Wrapper from 8.14.3 to 9.2.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/5751
+### Dependency upgrades
+* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5769
+* fix(deps): update datastore to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5789
+* chore(deps): update peter-evans/create-pull-request action to v7.0.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5793
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.4.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5795
+* fix(deps): update metro to v0.7.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5771
+* chore(deps): update plugin sonarqube to v7.1.0.6387 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5783
+* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5799
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5796
+* fix(deps): update dependency io.sentry:sentry-android to v8.27.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5803
+* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5801
+* fix(deps): update roborazzi to v1.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5804
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5814
+* chore(deps): update actions/checkout action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5805
+* fix(deps): update dependency com.google.testparameterinjector:test-parameter-injector to v1.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5800
+* fix(deps): update android.gradle.plugin to v8.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5260
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5818
+* fix(deps): update dependencyanalysis to v3.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5819
+* fix(deps): update dependency com.posthog:posthog-android to v3.27.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5834
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.16.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5839
+* Upgrade the Rust SDK to `v25.12.2` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5838
+### Others
+* misc : use newLatestEvent api from sdk by @ganfra in https://github.com/element-hq/element-x-android/pull/5809
+* Inject RoomMemberListDataSource in the presenter constructor. by @bmarty in https://github.com/element-hq/element-x-android/pull/5822
+* Add more performance checks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5767
+* Load `JoinedRoom` in home screen, pass it to the room flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5817
+* Revert "fix(deps): update dependency com.posthog:posthog-android to v3.27.0" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5836
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.3...v25.12.0
+
Changes in Element X v25.11.3
=============================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2464155cb4..322ad47911 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -199,6 +199,10 @@ android {
resources.pickFirsts += setOf(
"META-INF/versions/9/OSGI-INF/MANIFEST.MF",
)
+
+ jniLibs {
+ useLegacyPackaging = project.findProperty("useLegacyPackaging")?.toString()?.toBoolean()
+ }
}
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 96109425fa..b09ecf69ca 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -66,7 +66,10 @@
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
# Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code
--keep class org.matrix.rustcomponents.sdk.** { *;}
--keep class uniffi.** { *;}
--keep class io.element.android.x.di.** { *; }
--keepnames class io.element.android.x.**
+-keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
+-keep,allowshrinking class uniffi.** { *;}
+-keep,allowshrinking class io.element.android.x.di.** { *; }
+-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
+
+# Keep Metro classes
+-keep,allowshrinking class dev.zacsweers.metro.** { *; }
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
index 6e157f6ac1..6768d0d8db 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
@@ -17,6 +17,7 @@ import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.di.identifiers.SentrySdkDsn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.platform.InitPlatformService
import io.element.android.libraries.matrix.api.tracing.TracingService
@@ -48,4 +49,6 @@ interface AppBindings {
fun featureFlagService(): FeatureFlagService
fun buildMeta(): BuildMeta
+
+ fun sentrySdkDsn(): SentrySdkDsn?
}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
index 0eea5123b4..2a844b1331 100644
--- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
+++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
@@ -38,6 +38,7 @@ class PlatformInitializer : Initializer {
logLevel = logLevel,
extraTargets = listOf(ELEMENT_X_TARGET),
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
+ sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
)
bugReporter.setCurrentTracingLogLevel(logLevel.name)
platformService.init(tracingConfiguration)
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index a92d7b2ef9..a171646bad 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -15,6 +15,7 @@
+
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
index c0c152142e..855586892a 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
@@ -13,4 +13,5 @@ object LearnMoreConfig {
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
+ const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing"
}
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 539b67825d..d4fe7d1fc5 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
@@ -17,19 +17,19 @@ object TimelineConfig {
* Event types that will be filtered out from the timeline (i.e. not displayed).
*/
val excludedEvents = listOf(
- StateEventType.CALL_MEMBER,
- StateEventType.ROOM_ALIASES,
- StateEventType.ROOM_CANONICAL_ALIAS,
- StateEventType.ROOM_GUEST_ACCESS,
- StateEventType.ROOM_HISTORY_VISIBILITY,
- StateEventType.ROOM_JOIN_RULES,
- StateEventType.ROOM_POWER_LEVELS,
- StateEventType.ROOM_SERVER_ACL,
- StateEventType.ROOM_TOMBSTONE,
- StateEventType.SPACE_CHILD,
- StateEventType.SPACE_PARENT,
- StateEventType.POLICY_RULE_ROOM,
- StateEventType.POLICY_RULE_SERVER,
- StateEventType.POLICY_RULE_USER,
+ StateEventType.CallMember,
+ StateEventType.RoomAliases,
+ StateEventType.RoomCanonicalAlias,
+ StateEventType.RoomGuestAccess,
+ StateEventType.RoomHistoryVisibility,
+ StateEventType.RoomJoinRules,
+ StateEventType.RoomPowerLevels,
+ StateEventType.RoomServerAcl,
+ StateEventType.RoomTombstone,
+ StateEventType.SpaceChild,
+ StateEventType.SpaceParent,
+ StateEventType.PolicyRuleRoom,
+ StateEventType.PolicyRuleServer,
+ StateEventType.PolicyRuleUser,
)
}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index aa0bc04772..8bc821f933 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api)
+ implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api)
implementation(projects.services.apperror.impl)
@@ -62,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.forward.test)
+ testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 38dc39ee83..21bdd99a47 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -47,12 +47,14 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
+import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
@@ -123,6 +125,7 @@ class LoggedInFlowNode(
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
+ private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
@@ -142,6 +145,7 @@ class LoggedInFlowNode(
snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
+ private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -285,6 +289,9 @@ class LoggedInFlowNode(
@Parcelize
data object CreateRoom : NavTarget
+ @Parcelize
+ data object CreateSpace : NavTarget
+
@Parcelize
data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@@ -293,6 +300,9 @@ class LoggedInFlowNode(
@Parcelize
data object Ftue : NavTarget
+ @Parcelize
+ data object LinkNewDevice : NavTarget
+
@Parcelize
data object RoomDirectory : NavTarget
@@ -333,6 +343,10 @@ class LoggedInFlowNode(
backstack.push(NavTarget.CreateRoom)
}
+ override fun navigateToCreateSpace() {
+ backstack.push(NavTarget.CreateSpace)
+ }
+
override fun navigateToSetUpRecovery() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
@@ -419,6 +433,10 @@ class LoggedInFlowNode(
callback.navigateToAddAccount()
}
+ override fun navigateToLinkNewDevice() {
+ backstack.push(NavTarget.LinkNewDevice)
+ }
+
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
@@ -460,6 +478,14 @@ class LoggedInFlowNode(
callback = callback,
)
}
+ is NavTarget.CreateSpace -> {
+ val callback = object : CreateRoomEntryPoint.Callback {
+ override fun onRoomCreated(roomId: RoomId) {
+ backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList()))
+ }
+ }
+ createRoomEntryPoint.createNode(isSpace = true, parentNode = this, buildContext = buildContext, callback = callback)
+ }
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(
parentNode = this,
@@ -475,6 +501,14 @@ class LoggedInFlowNode(
NavTarget.Ftue -> {
ftueEntryPoint.createNode(this, buildContext)
}
+ NavTarget.LinkNewDevice -> {
+ val callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ linkNewDeviceEntryPoint.createNode(this, buildContext, callback)
+ }
NavTarget.RoomDirectory -> {
roomDirectoryEntryPoint.createNode(
parentNode = this,
@@ -499,9 +533,18 @@ class LoggedInFlowNode(
params = ShareEntryPoint.Params(intent = navTarget.intent),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List) {
+ // Remove the incoming share screen
backstack.pop()
+
+ // Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
- backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
+ sessionCoroutineScope.launch {
+ // Wait until the incoming share screen is removed
+ backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare }
+
+ // Then attach the room
+ attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
+ }
}
}
},
@@ -627,7 +670,21 @@ private class AttachRoomOperation(
operation = this
)
} else {
- Push(roomTarget).invoke(elements)
+ val existingRoomElement = elements.find {
+ val roomNavTarget = it.key.navTarget as? LoggedInFlowNode.NavTarget.Room
+ roomNavTarget?.roomIdOrAlias == roomTarget.roomIdOrAlias
+ }
+ if (existingRoomElement != null) {
+ elements.mapNotNull { element ->
+ if (element == existingRoomElement) {
+ null
+ } else {
+ element.transitionTo(STASHED, this)
+ }
+ } + existingRoomElement.transitionTo(ACTIVE, this)
+ } else {
+ Push(roomTarget).invoke(elements)
+ }
}
}
}
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 4687946367..1ec1678d71 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -320,7 +320,7 @@ class RootFlowNode(
is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
- analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
+ analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage)
}
navigateTo(resolvedIntent.deeplinkData)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
index 4af64cba34..c6a031921f 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
@@ -14,10 +14,13 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -36,6 +39,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
class MatrixSessionCache(
private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
+ private val analyticsService: AnalyticsService,
) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap()
private val restoreMutex = Mutex()
@@ -100,6 +104,11 @@ class MatrixSessionCache(
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
+ // Add the current homeserver (hashed) to the extra info
+ // This may not play well with multiple sessions, but it should work for now
+ analyticsService.addIndexableData(AnalyticsUserData.HOMESERVER, matrixClient.userIdServerName().hash())
+
+ // Add the new client to the in-memory cache
onNewMatrixClient(matrixClient)
}
.onFailure {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
index 53ce50a788..9b1bbd1b81 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@@ -77,7 +78,7 @@ class SyncOrchestrator(
// Wait until the sync service is not idle, either it will be running or in error/offline state
val firstState = syncService.syncState.first { it != SyncState.Idle }
- transaction.setData("first_sync_state", firstState.name)
+ transaction.putIndexableData(AnalyticsUserData.FIRST_SYNC_STATE, firstState.name)
}
observeStates()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
index cb78760a0f..371b067637 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
@@ -10,8 +10,10 @@ package io.element.android.appnav.di
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
interface TimelineBindings {
val timelineProvider: TimelineProvider
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
+ val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 1bf3516900..6446f754b0 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -49,7 +49,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
-import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
+import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted
@@ -128,7 +128,7 @@ class RoomFlowNode(
override fun onBuilt() {
super.onBuilt()
- val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
+ val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
index 5a6ef9133b..05ebab2b10 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
@@ -8,7 +8,9 @@
package io.element.android.appnav.room.joined
+import android.app.Activity
import android.os.Parcelable
+import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -96,6 +98,11 @@ class JoinedRoomLoadedFlowNode(
private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room)
+ private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher
+
+ // This is an ugly hack to check activity recreation
+ private var currentActivity: Activity? = null
+
init {
lifecycle.subscribe(
onCreate = {
@@ -104,6 +111,7 @@ class JoinedRoomLoadedFlowNode(
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
+ sendMessageWatcher?.start()
fetchRoomMembers()
trackVisitedRoom()
},
@@ -115,8 +123,13 @@ class JoinedRoomLoadedFlowNode(
},
onDestroy = {
Timber.v("OnDestroy")
- activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
- inputs.room.destroy()
+ sendMessageWatcher?.stop()
+ // If we're just going through an activity recreation there's no need to destroy the Room object
+ // Destroying it would actually cause an issue where its methods can no longer be called
+ if (currentActivity?.isChangingConfigurations != true) {
+ activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
+ inputs.room.destroy()
+ }
appNavigationStateService.onLeavingRoom(id)
}
)
@@ -289,6 +302,8 @@ class JoinedRoomLoadedFlowNode(
@Composable
override fun View(modifier: Modifier) {
+ currentActivity = LocalActivity.current
+
BackstackView()
}
}
diff --git a/appnav/src/main/res/values-hr/translations.xml b/appnav/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..f75fa613f2
--- /dev/null
+++ b/appnav/src/main/res/values-hr/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Odjava i nadogradnja"
+ "%1$s više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom."
+ "Vaš matični poslužitelj više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom."
+
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
index 6cd7df025b..8d514a2c0f 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
@@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory
+import io.element.android.appnav.di.TimelineBindings
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
+import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
+import io.element.android.features.messages.test.pinned.FakePinnedEventsTimelineProvider
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
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.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
+import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMessageWatcher
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@@ -72,9 +79,20 @@ class JoinedRoomLoadedFlowNodeTest {
}
}
- private class FakeRoomGraphFactory : RoomGraphFactory {
+ private class FakeRoomGraphFactory(
+ private val timelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
+ private val pinnedEventsTimelineProvider: FakePinnedEventsTimelineProvider = FakePinnedEventsTimelineProvider(),
+ private val analyticsSendMessageWatcher: FakeAnalyticsSendMessageWatcher = FakeAnalyticsSendMessageWatcher(),
+ ) : RoomGraphFactory {
override fun create(room: JoinedRoom): Any {
- return Unit
+ return object : TimelineBindings {
+ override val timelineProvider: TimelineProvider
+ get() = this@FakeRoomGraphFactory.timelineProvider
+ override val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
+ get() = this@FakeRoomGraphFactory.pinnedEventsTimelineProvider
+ override val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
+ get() = this@FakeRoomGraphFactory.analyticsSendMessageWatcher
+ }
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
index 56c20f7a1d..36fa471dd0 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
@@ -19,29 +19,31 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
+@OptIn(ExperimentalCoroutinesApi::class)
class MatrixSessionCacheTest {
@Test
fun `test getOrNull`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
// With no matrix client there is no sync orchestrator
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
// But as soon as we receive a client, we can get the sync orchestrator
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
@@ -50,8 +52,8 @@ class MatrixSessionCacheTest {
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
@@ -63,8 +65,8 @@ class MatrixSessionCacheTest {
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
@@ -76,8 +78,8 @@ class MatrixSessionCacheTest {
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
@@ -89,8 +91,8 @@ class MatrixSessionCacheTest {
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixSessionCache.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
@@ -109,29 +111,45 @@ class MatrixSessionCacheTest {
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
- fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
+
+ runCurrent()
+
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
}
- private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
- override fun create(
- syncService: SyncService,
- sessionCoroutineScope: CoroutineScope,
- ): SyncOrchestrator {
- return SyncOrchestrator(
- syncService = syncService,
- sessionCoroutineScope = sessionCoroutineScope,
- appForegroundStateService = FakeAppForegroundStateService(),
- networkMonitor = FakeNetworkMonitor(),
- dispatchers = testCoroutineDispatchers(),
- analyticsService = FakeAnalyticsService(),
- )
+ private fun TestScope.createMatrixSessionCache(
+ authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
+ syncOrchestratorFactory: SyncOrchestrator.Factory = createSyncOrchestratorFactory(),
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
+ ) = MatrixSessionCache(
+ authenticationService = authenticationService,
+ syncOrchestratorFactory = syncOrchestratorFactory,
+ analyticsService = analyticsService,
+ )
+
+ private fun TestScope.createSyncOrchestratorFactory(): SyncOrchestrator.Factory {
+ val dispatchers = testCoroutineDispatchers()
+
+ return object : SyncOrchestrator.Factory {
+ override fun create(
+ syncService: SyncService,
+ sessionCoroutineScope: CoroutineScope,
+ ): SyncOrchestrator {
+ return SyncOrchestrator(
+ syncService = syncService,
+ sessionCoroutineScope = sessionCoroutineScope,
+ appForegroundStateService = FakeAppForegroundStateService(),
+ networkMonitor = FakeNetworkMonitor(),
+ dispatchers = dispatchers,
+ analyticsService = FakeAnalyticsService(),
+ )
+ }
}
}
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 19aaf78405..61e17c149c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.4.28")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.5.3")
detektPlugins(project(":tests:detekt-rules"))
}
diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
index 7ddf80b92c..8b537a53fe 100644
--- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
+++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
@@ -91,7 +91,7 @@ class ContributesNodeProcessor(
.addAnnotation(Binds::class)
.addAnnotation(IntoMap::class)
.addAnnotation(
- AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
+ AnnotationSpec.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).build()
diff --git a/fastlane/metadata/android/en-US/changelogs/202601000.txt b/fastlane/metadata/android/en-US/changelogs/202601000.txt
new file mode 100644
index 0000000000..98c450dce0
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202601000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: iterated on spaces, improved the room list stability and performance, and a long list of bug fixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/features/analytics/api/src/main/res/values-hr/translations.xml b/features/analytics/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..ac1e83c87b
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema."
+ "Možete pročitati sve naše uvjete %1$s ."
+ "ovdje"
+ "Dijeljenje analitičkih podataka"
+
diff --git a/features/analytics/impl/src/main/res/values-hr/translations.xml b/features/analytics/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..b85046c7a9
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nećemo bilježiti niti profilirati nikakve osobne podatke"
+ "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema."
+ "Možete pročitati sve naše uvjete %1$s ."
+ "ovdje"
+ "Ovo možete isključiti u bilo kojem trenutku"
+ "Nećemo dijeliti vaše podatke s trećim stranama"
+ "Pomozite nam poboljšati %1$s"
+
diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
index e0759bc490..3fe6ec4456 100644
--- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
+++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
@@ -81,7 +81,7 @@ private fun SpaceAnnouncementHeader(
showBetaLabel = true,
subTitle = stringResource(id = R.string.screen_space_announcement_subtitle),
iconStyle = BigIcon.Style.Default(
- vectorIcon = CompoundIcons.WorkspaceSolid(),
+ vectorIcon = CompoundIcons.SpaceSolid(),
usePrimaryTint = true,
),
)
diff --git a/features/announcement/impl/src/main/res/values-hr/translations.xml b/features/announcement/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..e78f29f19a
--- /dev/null
+++ b/features/announcement/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Pregledajte prostore koje ste stvorili ili kojima ste se pridružili"
+ "Prihvatite ili odbijte pozivnice za prostore"
+ "Otkrijte sve sobe kojima se možete pridružiti u svojim prostorima"
+ "Pridružite se javnim prostorima"
+ "Napustite sve prostore kojima ste se pridružili"
+ "Uskoro stiže filtriranje i stvaranje prostora te upravljanje njima."
+ "Dobrodošli u beta inačicu prostora! S ovom prvom inačicom možete:"
+ "Predstavljamo prostore"
+
diff --git a/features/announcement/impl/src/main/res/values-ro/translations.xml b/features/announcement/impl/src/main/res/values-ro/translations.xml
index 48fa06fca4..716f1faeb2 100644
--- a/features/announcement/impl/src/main/res/values-ro/translations.xml
+++ b/features/announcement/impl/src/main/res/values-ro/translations.xml
@@ -5,7 +5,7 @@
"Descoperiți toate camerele la care vă puteți alătura în spațiile dumneavoastră."
"Alăturați-vă spațiilor publice"
"Părăsiți spațiile la care v-ați alăturat."
- "Crearea și gestionarea spațiilor vor fi disponibile în curând."
+ "Filtrarea, crearea și gestionarea spațiilor vor fi disponibile în curând."
"Bun venit la versiunea beta a Spațiilor! Cu această primă versiune puteți:"
"Vă prezentăm Spații"
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 91fcc2593e..4183e22531 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -177,8 +177,8 @@ class DefaultActiveCallManager(
suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
Timber.tag(tag).d("Incoming call timed out")
- val previousActiveCall = activeCall.value ?: return
- val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
+ val previousActiveCall = activeCall.value ?: return@withLock
+ val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return@withLock
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after timeout")
@@ -196,11 +196,11 @@ class DefaultActiveCallManager(
Timber.tag(tag).d("Hung up call: $callType")
val currentActiveCall = activeCall.value ?: run {
Timber.tag(tag).w("No active call, ignoring hang up")
- return
+ return@withLock
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
- return
+ return@withLock
}
if (currentActiveCall.callState is CallState.Ringing) {
// Decline the call
diff --git a/features/call/impl/src/main/res/values-hr/translations.xml b/features/call/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..e58dc362fa
--- /dev/null
+++ b/features/call/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Poziv u tijeku"
+ "Dodirnite za povratak u poziv"
+ "☎️ Poziv u tijeku"
+ "Element Call ne podržava korištenje Bluetooth audiouređaja u ovoj inačici Androida. Odaberite drugi audiouređaj."
+ "Dolazni Element Call"
+
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
index 1c6a9f04db..22757aba06 100644
--- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
index 7fea6fc0e5..89dbddd186 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
@@ -24,6 +24,7 @@ import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@@ -37,23 +38,29 @@ class CreateRoomFlowNode(
@Assisted plugins: List,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = NavTarget.ConfigureRoom,
+ initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance().first().isSpace),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
+ @Parcelize
+ data class Inputs(
+ val isSpace: Boolean
+ ) : NodeInputs, Parcelable
+
private val callback: CreateRoomEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.ConfigureRoom -> {
+ is NavTarget.ConfigureRoom -> {
+ val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace)
val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.replace(NavTarget.AddPeople(roomId))
}
}
- createNode(buildContext, plugins = listOf(callback))
+ createNode(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.AddPeople -> {
val inputs = AddPeopleNode.Inputs(navTarget.roomId)
@@ -74,7 +81,7 @@ class CreateRoomFlowNode(
sealed interface NavTarget : Parcelable {
@Parcelize
- data object ConfigureRoom : NavTarget
+ data class ConfigureRoom(val isSpace: Boolean) : NavTarget
@Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
index 2261d294cf..63163e7a28 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
@@ -18,10 +18,12 @@ import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node {
- return parentNode.createNode(buildContext, listOf(callback))
+ val inputs = CreateRoomFlowNode.Inputs(isSpace)
+ return parentNode.createNode(buildContext, listOf(inputs, callback))
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
index 43ceee3594..e021a7a0f9 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
@@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.configureroom
+import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
@@ -18,23 +19,35 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class ConfigureRoomNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: ConfigureRoomPresenter,
+ presenterFactory: ConfigureRoomPresenter.Factory,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCreateRoomSuccess(roomId: RoomId)
}
+ @Parcelize
+ data class Inputs(
+ val isSpace: Boolean,
+ ) : NodeInputs, Parcelable
+
+ private val inputs = inputs()
+
+ private val presenter = presenterFactory.create(inputs.isSpace)
+
init {
lifecycle.subscribe(
onResume = {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index c5d68e127f..aef55c77df 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -19,7 +19,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.net.toUri
-import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
@@ -40,7 +42,7 @@ import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEf
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
@@ -49,8 +51,9 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault
-@Inject
+@AssistedInject
class ConfigureRoomPresenter(
+ @Assisted private val isSpace: Boolean,
private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
@@ -61,13 +64,22 @@ class ConfigureRoomPresenter(
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(isSpace: Boolean): ConfigureRoomPresenter
+ }
+
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
+ init {
+ dataStore.setIsSpace(isSpace)
+ }
+
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
- val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState(CreateRoomConfig())
+ val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
@@ -132,7 +144,7 @@ class ConfigureRoomPresenter(
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
@@ -171,7 +183,8 @@ class ConfigureRoomPresenter(
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
- roomAliasName = config.roomVisibility.roomAddress()
+ roomAliasName = config.roomVisibility.roomAddress(),
+ isSpace = isSpace,
)
} else {
CreateRoomParameters(
@@ -184,6 +197,7 @@ class ConfigureRoomPresenter(
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
+ isSpace = isSpace,
)
}
matrixClient.createRoom(params)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
index 7f760b46fb..dc78ed7aab 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
@@ -78,6 +78,18 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider Unit,
modifier: Modifier = Modifier,
) {
+ val isSpace = state.config.isSpace
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@@ -81,6 +87,7 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
+ isSpace = isSpace,
isNextActionEnabled = state.isValid,
onBackClick = onBackClick,
onNextClick = {
@@ -96,9 +103,10 @@ fun ConfigureRoomView(
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
- verticalArrangement = Arrangement.spacedBy(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomNameWithAvatar(
+ isSpace = isSpace,
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
@@ -110,37 +118,35 @@ fun ConfigureRoomView(
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
- RoomVisibilityOptions(
+
+ RoomVisibilityAndAccessOptions(
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
- is RoomVisibilityState.Public -> RoomVisibilityItem.Public
+ is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) {
+ RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin
+ RoomAccess.Anyone -> RoomVisibilityItem.Public
+ }
},
+ isKnockingEnabled = state.isKnockFeatureEnabled,
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
},
)
- if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) {
- RoomAccessOptions(
- selected = when (state.config.roomVisibility.roomAccess) {
- RoomAccess.Anyone -> RoomAccessItem.Anyone
- RoomAccess.Knocking -> RoomAccessItem.AskToJoin
- },
- onOptionClick = {
- focusManager.clearFocus()
- state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it))
- },
- )
- RoomAddressField(
- modifier = Modifier.padding(horizontal = 16.dp),
- address = state.config.roomVisibility.roomAddress.value,
- homeserverName = state.homeserverName,
- addressValidity = state.roomAddressValidity,
- onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
- label = stringResource(R.string.screen_create_room_room_address_section_title),
- supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
- )
- Spacer(Modifier)
+
+ if (state.config.roomVisibility !is RoomVisibilityState.Private) {
+ Column {
+ ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
+ RoomAddressField(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(),
+ homeserverName = state.homeserverName,
+ addressValidity = state.roomAddressValidity,
+ onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
+ label = null,
+ supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
+ )
+ }
}
}
}
@@ -156,11 +162,11 @@ fun ConfigureRoomView(
async = state.createRoomAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
- progressText = stringResource(CommonStrings.common_creating_room),
+ progressText = stringResource(if (isSpace) CommonStrings.common_creating_space else CommonStrings.common_creating_room),
)
},
onSuccess = { onCreateRoomSuccess(it) },
- errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
+ errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
)
@@ -173,12 +179,13 @@ fun ConfigureRoomView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigureRoomToolbar(
+ isSpace: Boolean,
isNextActionEnabled: Boolean,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
) {
TopAppBar(
- titleStr = stringResource(R.string.screen_create_room_title),
+ titleStr = stringResource(if (isSpace) R.string.screen_create_room_new_space_title else R.string.screen_create_room_new_room_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
@@ -192,6 +199,7 @@ private fun ConfigureRoomToolbar(
@Composable
private fun RoomNameWithAvatar(
+ isSpace: Boolean,
avatarUri: String?,
roomName: String,
onAvatarClick: () -> Unit,
@@ -203,25 +211,33 @@ private fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- val a11yAvatar = stringResource(CommonStrings.a11y_room_avatar)
- UnsavedAvatar(
- avatarUri = avatarUri,
- avatarSize = AvatarSize.EditRoomDetails,
- avatarType = AvatarType.Room(),
- modifier = Modifier
- .clickable(
- onClick = onAvatarClick,
- onClickLabel = stringResource(CommonStrings.action_open_context_menu),
- )
- .clearAndSetSemantics {
- contentDescription = a11yAvatar
- },
- )
+ Box(
+ modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ val avatarState = remember(avatarUri) {
+ if (avatarUri != null) {
+ AvatarPickerState.Selected(
+ avatarData = AvatarData(id = "#", name = null, url = avatarUri, size = AvatarSize.EditRoomDetails),
+ type = if (isSpace) AvatarType.Space() else AvatarType.Room(),
+ )
+ } else {
+ val containerSize = 48.dp
+ val padding = PaddingValues((AvatarSize.EditRoomDetails.dp - containerSize) / 2)
+ AvatarPickerState.Pick(buttonSize = 48.dp, iconSize = 24.dp, externalPadding = padding)
+ }
+ }
+ AvatarPickerView(
+ state = avatarState,
+ onClick = onAvatarClick,
+ )
+ }
TextField(
- label = stringResource(R.string.screen_create_room_room_name_label),
+ modifier = Modifier.padding(bottom = 18.dp),
+ label = stringResource(CommonStrings.common_name),
value = roomName,
- placeholder = stringResource(CommonStrings.common_room_name_placeholder),
+ placeholder = stringResource(R.string.screen_create_room_name_placeholder),
singleLine = true,
onValueChange = onChangeRoomName,
)
@@ -240,7 +256,7 @@ private fun RoomTopic(
value = topic,
onValueChange = onTopicChange,
maxLines = 3,
- supportingText = stringResource(CommonStrings.common_topic_placeholder),
+ placeholder = stringResource(R.string.screen_create_room_topic_placeholder),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
@@ -256,38 +272,58 @@ private fun ConfigureRoomOptions(
Column(
modifier = modifier.selectableGroup()
) {
- Text(
- text = title,
- style = ElementTheme.typography.fontBodyLgMedium,
- color = ElementTheme.colors.textPrimary,
- modifier = Modifier.padding(horizontal = 16.dp),
- )
+ ListSectionHeader(title = title)
content()
}
}
@Composable
-private fun RoomVisibilityOptions(
+private fun RoomVisibilityAndAccessOptions(
selected: RoomVisibilityItem,
+ isKnockingEnabled: Boolean,
onOptionClick: (RoomVisibilityItem) -> Unit,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
- title = stringResource(R.string.screen_create_room_room_visibility_section_title),
+ title = stringResource(R.string.screen_create_room_room_access_section_title),
modifier = modifier,
) {
RoomVisibilityItem.entries.forEach { item ->
+ if (item == RoomVisibilityItem.AskToJoin && !isKnockingEnabled) {
+ return@forEach
+ }
+
val isSelected = item == selected
ListItem(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
- resourceId = item.icon,
+ resourceId = when (item) {
+ RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public
+ RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add
+ RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock
+ },
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
+ backgroundTint = Color.Transparent,
)
},
- headlineContent = { Text(text = stringResource(item.title)) },
- supportingContent = { Text(text = stringResource(item.description)) },
+ headlineContent = {
+ val title = when (item) {
+ RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title)
+ RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
+ RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title)
+ }
+ Text(text = title)
+ },
+ supportingContent = {
+ // TODO handle description of items in a certain space/org
+ val description = when (item) {
+ RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description)
+ RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
+ RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description)
+ }
+ Text(text = description)
+ },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onOptionClick(item) },
)
@@ -295,27 +331,6 @@ private fun RoomVisibilityOptions(
}
}
-@Composable
-private fun RoomAccessOptions(
- selected: RoomAccessItem,
- onOptionClick: (RoomAccessItem) -> Unit,
- modifier: Modifier = Modifier,
-) {
- ConfigureRoomOptions(
- title = stringResource(R.string.screen_create_room_room_access_section_header),
- modifier = modifier,
- ) {
- RoomAccessItem.entries.forEach { item ->
- ListItem(
- headlineContent = { Text(text = stringResource(item.title)) },
- supportingContent = { Text(text = stringResource(item.description)) },
- trailingContent = ListItemContent.RadioButton(selected = item == selected),
- onClick = { onOptionClick(item) },
- )
- }
- }
-}
-
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
index b9d05a144f..8355047ddf 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
@@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
+ val isSpace: Boolean = false,
val roomName: String? = null,
val topic: String? = null,
val avatarUri: String? = null,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
index 5e8637ddd6..81c9638f0c 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
@@ -72,11 +72,11 @@ class CreateRoomConfigStore(
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
- RoomVisibilityItem.Public -> {
+ RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
- roomAccess = RoomAccess.Anyone,
+ roomAccess = if (visibility == RoomVisibilityItem.AskToJoin) RoomAccess.Knocking else RoomAccess.Anyone,
)
}
}
@@ -114,6 +114,12 @@ class CreateRoomConfigStore(
}
}
+ fun setIsSpace(isSpace: Boolean) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(isSpace = isSpace)
+ }
+ }
+
fun clearCachedData() {
cachedAvatarUri = null
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
index 2d37be9103..9d140b952c 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
@@ -8,19 +8,7 @@
package io.element.android.features.createroom.impl.configureroom
-import androidx.annotation.StringRes
-import io.element.android.features.createroom.impl.R
-
-enum class RoomAccessItem(
- @StringRes val title: Int,
- @StringRes val description: Int
-) {
- Anyone(
- title = R.string.screen_create_room_room_access_section_anyone_option_title,
- description = R.string.screen_create_room_room_access_section_anyone_option_description,
- ),
- AskToJoin(
- title = R.string.screen_create_room_room_access_section_knocking_option_title,
- description = R.string.screen_create_room_room_access_section_knocking_option_description,
- ),
+enum class RoomAccessItem {
+ Anyone,
+ AskToJoin,
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
index b92dee4d6e..feff1ee90f 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
@@ -8,24 +8,8 @@
package io.element.android.features.createroom.impl.configureroom
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import io.element.android.features.createroom.impl.R
-import io.element.android.libraries.designsystem.icons.CompoundDrawables
-
-enum class RoomVisibilityItem(
- @DrawableRes val icon: Int,
- @StringRes val title: Int,
- @StringRes val description: Int
-) {
- Private(
- icon = CompoundDrawables.ic_compound_lock,
- title = R.string.screen_create_room_private_option_title,
- description = R.string.screen_create_room_private_option_description,
- ),
- Public(
- icon = CompoundDrawables.ic_compound_public,
- title = R.string.screen_create_room_public_option_title,
- description = R.string.screen_create_room_public_option_description,
- )
+enum class RoomVisibilityItem {
+ Public,
+ AskToJoin,
+ Private
}
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index f5d6a234e2..4cc6aee822 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -4,14 +4,10 @@
"Запрасіць карыстальнікаў"
"Пры стварэнні пакоя адбылася памылка"
"Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем."
- "Прыватны пакой"
"Любы можа знайсці гэты пакой.
Вы можаце змяніць гэта ў любы час у наладах пакоя."
- "Публічны пакой"
- "Хто заўгодна"
- "Доступ у пакой"
+ "Публічны пакой (для ўсіх)"
"Папрасіце далучыцца"
- "Назва пакоя"
- "Стварыце пакой"
+ "Хто заўгодна"
"Тэма (неабавязкова)"
diff --git a/features/createroom/impl/src/main/res/values-bg/translations.xml b/features/createroom/impl/src/main/res/values-bg/translations.xml
index 249058b7af..4c6bfed0e4 100644
--- a/features/createroom/impl/src/main/res/values-bg/translations.xml
+++ b/features/createroom/impl/src/main/res/values-bg/translations.xml
@@ -4,15 +4,12 @@
"Поканване на хора"
"Възникна грешка при създаването на стаята"
"Само поканени хора имат достъп до тази стая. Всички съобщения са шифровани от край до край."
- "Частна стая"
"Всеки може да намери тази стая.
Можете да промените това по всяко време в настройките на стаята."
- "Общодостъпна стая"
- "Всеки може да се присъедини към тази стая"
- "Всеки"
+ "Публична стая (всеки)"
+ "Всеки може да се присъедини към тази стая"
+ "Всеки"
"За да бъде тази стая видима в директорията на общодостъпните стаи, ще ви е необходим адрес на стаята."
- "Име на стаята"
"Видимост на стаята"
- "Създаване на стая"
"Тема за разговор (незадължително)"
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
index e19cfbcf91..39f9d573e0 100644
--- a/features/createroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -4,19 +4,15 @@
"Pozvat přátele"
"Při vytváření místnosti došlo k chybě"
"Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány."
- "Soukromá místnost"
"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."
"Veřejná místnost"
- "Do této místnosti může vstoupit kdokoli"
- "Kdokoliv"
- "Přístup do místnosti"
"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"
"Požádat o připojení"
+ "Do této místnosti může vstoupit kdokoli"
+ "Kdokoliv"
"Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."
"Adresa místnosti"
- "Název místnosti"
"Viditelnost místnosti"
- "Vytvořit místnost"
"Téma (nepovinné)"
diff --git a/features/createroom/impl/src/main/res/values-cy/translations.xml b/features/createroom/impl/src/main/res/values-cy/translations.xml
index 52168014bf..9ac9e5e3bc 100644
--- a/features/createroom/impl/src/main/res/values-cy/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cy/translations.xml
@@ -4,19 +4,15 @@
"Gwahodd pobl"
"Bu gwall wrth greu\'r ystafell"
"Dim ond pobl wahoddwyd all gael mynediad i\'r ystafell hon. Mae pob neges wedi\'i hamgryptio o\'r dechrau i\'r diwedd."
- "Ystafell breifat"
"Gall unrhyw un ddod o hyd i\'r ystafell hon.
Gallwch newid hyn unrhyw bryd yng ngosodiadau ystafell."
"Ystafell gyhoeddus"
- "Gall unrhyw un ymuno â\'r ystafell hon"
- "Unrhyw un"
- "Mynediad i\'r Ystafell"
"Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais"
"Gofyn i gael ymuno"
+ "Gall unrhyw un ymuno â\'r ystafell hon"
+ "Unrhyw un"
"Er mwyn i\'r ystafell hon fod yn weladwy yn y cyfeiriadur ystafelloedd cyhoeddus, bydd angen cyfeiriad ystafell arnoch."
"Cyfeiriad yr ystafell"
- "Enw\'r ystafell"
"Gwelededd yr ystafell"
- "Creu ystafell"
"Pwnc (dewisol)"
diff --git a/features/createroom/impl/src/main/res/values-da/translations.xml b/features/createroom/impl/src/main/res/values-da/translations.xml
index 422aae09bd..13d8b01b62 100644
--- a/features/createroom/impl/src/main/res/values-da/translations.xml
+++ b/features/createroom/impl/src/main/res/values-da/translations.xml
@@ -4,18 +4,14 @@
"Invitér andre"
"Der opstod en fejl ved oprettelsen af rummet"
"Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret."
- "Privat rum"
"Alle kan finde dette rum.
Du kan ændre dette når som helst i rummets indstillinger."
- "Offentligt rum"
- "Alle kan deltage i dette rum"
- "Enhver"
- "Adgang til rummet"
"Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen"
"Spørg om at deltage"
+ "Alle kan deltage i dette rum"
+ "Enhver"
"Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse."
- "Navn på rum"
+ "Rummets adresse"
"Rummets synlighed"
- "Opret et rum"
"Emne (valgfrit)"
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 9c48001c92..55d7a0b66e 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -3,20 +3,26 @@
"Neuer Chat"
"Nutzer einladen"
"Beim Erstellen des Chats ist ein Fehler aufgetreten"
- "Nur eingeladene Personen haben Zutritt zu diesem Chat. Alle Nachrichten sind Ende-zu-Ende verschlüsselt."
- "Privater Chat"
+ "Der Space konnte wegen eines unbekannten Fehlers nicht erstellt werden. Versuch\' es später nochmal."
+ "Name hinzufügen…"
+ "Neuer Chat"
+ "Neuer Space"
+ "Nur eingeladene Personen haben Zutritt zu diesem Chat."
+ "Privat"
"Jeder kann diesen Chat finden.
Du kannst dies jederzeit in den Einstellungen des Chats ändern."
- "Öffentlicher Chat"
- "Jeder darf diesem Chat beitreten"
- "Jeder"
- "Chat Zugang"
+ "Jeder kann beitreten."
+ "Öffentlicher Chatroom"
"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."
- "Beitritt beantragen"
- "Du benötigst eine Chat-Adresse, damit dieser Chat im öffentlichen Verzeichnis sichtbar ist."
- "Chatroom Adresse"
- "Chat-Name"
+ "Anfrage zum Beitritt zulassen"
+ "Nur eingeladene Personen können beitreten."
+ "Privat"
+ "Jeder darf diesem Chat beitreten."
+ "Jeder"
+ "Wer hat Zugang"
+ "Du benötigst eine Adresse, um diesen Chat im öffentlichen Verzeichnis sichtbar zu machen."
+ "Adresse"
" Sichtbarkeit des Chats"
- "Chat erstellen"
"Thema (optional)"
+ "Beschreibung hinzufügen…"
diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml
index 37ccd49d0e..3b1c94c443 100644
--- a/features/createroom/impl/src/main/res/values-el/translations.xml
+++ b/features/createroom/impl/src/main/res/values-el/translations.xml
@@ -4,19 +4,15 @@
"Πρόσκληση ατόμων"
"Προέκυψε σφάλμα κατά τη δημιουργία της αίθουσας"
"Μόνο τα άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτή την αίθουσα. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο."
- "Ιδιωτική αίθουσα"
"Ο καθένας μπορεί να βρει αυτή την αίθουσα.
Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."
- "Δημόσια αίθουσα"
- "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα"
- "Οποιοσδήποτε"
- "Πρόσβαση στην Αίθουσα"
+ "Δημόσιο δωμάτιο"
"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα"
"Αίτημα συμμετοχής"
+ "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα"
+ "Οποιοσδήποτε"
"Για να είναι ορατή αυτή η αίθουσα στον δημόσιο κατάλογο αιθουσών, θα χρειαστείτε μια διεύθυνση αίθουσας."
"Διεύθυνση δωματίου"
- "Όνομα αίθουσας"
"Ορατότητα αίθουσας"
- "Δημιουργία αίθουσας"
"Θέμα (προαιρετικό)"
diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml
index 64806977c0..34362f537d 100644
--- a/features/createroom/impl/src/main/res/values-es/translations.xml
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -4,18 +4,14 @@
"Invitar personas"
"Se ha producido un error al crear la sala"
"Solo las personas invitadas pueden acceder a esta sala. Todos los mensajes están cifrados de extremo a extremo."
- "Sala privada"
"Cualquiera puede encontrar esta sala.
Puedes cambiar esto en cualquier momento en los ajustes de la sala."
- "Sala pública"
- "Cualquiera puede unirse a esta sala"
- "Cualquiera"
- "Acceso a la sala"
+ "Sala pública (cualquiera)"
"Cualquiera puede solicitar unirse a la sala, pero un administrador o un moderador tendrá que aceptar la solicitud"
"Solicitud para unirse"
+ "Cualquiera puede unirse a esta sala"
+ "Cualquiera"
"Para que esta sala sea visible en el directorio de salas públicas, necesitarás una dirección de sala."
- "Nombre de la sala"
"Visibilidad de la sala"
- "Crear una sala"
"Tema (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml
index 6a1d9dc58a..0527302e0e 100644
--- a/features/createroom/impl/src/main/res/values-et/translations.xml
+++ b/features/createroom/impl/src/main/res/values-et/translations.xml
@@ -4,19 +4,15 @@
"Kutsu osalejaid"
"Jututoa loomisel tekkis viga"
"Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud."
- "Privaatne jututuba"
"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."
"Avalik jututuba"
- "Kõik võivad selle jututoaga liituda"
- "Kõik"
- "Ligipääs jututoale"
"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"
"Küsi võimalust liitumiseks"
+ "Kõik võivad selle jututoaga liituda"
+ "Kõik kasutajad"
"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."
"Jututoa aadress"
- "Jututoa nimi"
"Jututoa nähtavus"
- "Loo jututuba"
"Teema (kui soovid lisada)"
diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml
index 537aa495a5..7e4fefe08f 100644
--- a/features/createroom/impl/src/main/res/values-eu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-eu/translations.xml
@@ -4,16 +4,12 @@
"Gonbidatu jendea"
"Errorea gertatu da gela sortzean"
"Gonbidatutako jendea soilik sar daiteke gelara. Mezu guztiak daude ertzetik ertzera zifratuta."
- "Gela pribatua"
"Edonork aurki dezake gela hau.
Gelaren ezarpenetan aldatu dezakezu hobespena."
"Gela publikoa"
- "Edonor sar daiteke gela honetara"
- "Edonork"
- "Gelarako sarbidea"
+ "Edonor sar daiteke gela honetara"
+ "Edonork"
"Gelaren helbidea"
- "Gelaren izena"
"Gelaren ikusgarritasuna"
- "Sortu gela"
"Mintzagaia (aukerakoa)"
diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml
index 09869c76f6..499b0a24ad 100644
--- a/features/createroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fa/translations.xml
@@ -7,14 +7,11 @@
"اتاق خصوصی"
"هرکسی میتواند اتاق را بیابد.
میتوانید بعداً در تظیمات اتاق عوضش کنید."
- "اتاق عمومی"
- "هرکسی میتواند به این اتاق بپیوندد"
- "هرکسی"
- "دسترسی اتاق"
+ "اتاق عمومی (هرکسی)"
"درخواست دعوت"
+ "هرکسی میتواند به این اتاق بپیوندد"
+ "هرکسی"
"نشانی اتاق"
- "نام اتاق"
"نمایانی اتاق"
- "ایجاد اتاق"
"موضوع (اختیاری)"
diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml
index df541d3dee..e436983989 100644
--- a/features/createroom/impl/src/main/res/values-fi/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fi/translations.xml
@@ -4,19 +4,15 @@
"Kutsu henkilöitä"
"Huoneen luomisessa tapahtui virhe"
"Vain kutsutut henkilöt pääsevät tähän huoneeseen. Kaikki viestit ovat päästä päähän salattuja."
- "Yksityinen huone"
"Kuka tahansa voi löytää tämän huoneen.
Voit muuttaa tämän milloin tahansa huoneen asetuksista."
"Julkinen huone"
- "Kuka tahansa voi liittyä tähän huoneeseen"
- "Kuka tahansa"
- "Huoneeseen Pääsy"
"Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö"
"Pyydä liittymistä"
+ "Kuka tahansa voi liittyä tähän huoneeseen"
+ "Kuka tahansa"
"Jotta tämä huone näkyisi julkisessa huonehakemistossa, tarvitset huoneen osoitteen."
"Huoneen osoite"
- "Huoneen nimi"
"Huoneen näkyvyys"
- "Luo huone"
"Aihe (valinnainen)"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index afbdc919ba..72b5314893 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -3,20 +3,26 @@
"Nouveau salon"
"Inviter des amis"
"Une erreur s’est produite lors de la création du salon"
- "Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout."
- "Salon privé"
+ "L’espace n’a pas pu être créé à cause d’une erreur inconnue. Réessayez plus tard."
+ "Ajouter un nom…"
+ "Nouveau salon"
+ "Nouvel espace"
+ "Seules les personnes invitées peuvent joindre."
+ "Privé"
"N’importe qui peut trouver ce salon.
Vous pouvez modifier cela à tout moment dans les paramètres du salon."
+ "Tout le monde peut joindre"
"Salon public"
- "Tout le monde peut rejoindre ce salon"
- "Tout le monde"
- "Accès au salon"
- "Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande"
- "Demander à rejoindre"
- "Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin d’une adresse de salon."
- "Adresse du salon"
- "Nom du salon"
+ "Tout le monde peut demander à joindre, mais un administrateur ou un modérateur devra accepter la demande"
+ "Autoriser la demande à joindre"
+ "Seules les personnes invitées peuvent joindre."
+ "Privé"
+ "Tout le monde peut joindre"
+ "Tout le monde"
+ "Qui a accès"
+ "Vous aurez besoin d’une adresse pour qu’il soit visible dans le répertoire public."
+ "Adresse"
"Visibilité du salon"
- "Créer un salon"
"Sujet (facultatif)"
+ "Ajouter une description…"
diff --git a/features/createroom/impl/src/main/res/values-hr/translations.xml b/features/createroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..1a23597ff4
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Nova soba"
+ "Pozovi osobe"
+ "Došlo je do pogreške prilikom stvaranja sobe"
+ "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."
+ "Svatko može pronaći ovu sobu.
+To možete u svakom trenutku promijeniti u postavkama sobe."
+ "Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev."
+ "Zatraži pridruživanje"
+ "Svatko se može pridružiti ovoj sobi"
+ "Svatko"
+ "Da bi ova soba bila vidljiva u javnom direktoriju soba, trebat će vam adresa sobe."
+ "Adresa sobe"
+ "Vidljivost sobe"
+ "Tema (neobavezno)"
+
diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml
index 24f71983ce..2e80833b2e 100644
--- a/features/createroom/impl/src/main/res/values-hu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-hu/translations.xml
@@ -4,19 +4,15 @@
"Ismerősök meghívása"
"Hiba történt a szoba létrehozásakor"
"Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve."
- "Privát szoba"
"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."
"Nyilvános szoba"
- "Bárki csatlakozhat ehhez a szobához"
- "Bárki"
- "Szobahozzáférés"
"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"
"Csatlakozás kérése"
+ "Bárki csatlakozhat ehhez a szobához"
+ "Bárki"
"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."
"Szoba címe"
- "Szoba neve"
"Szoba láthatósága"
- "Szoba létrehozása"
"Téma (nem kötelező)"
diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml
index 219f621068..20f23fd5c9 100644
--- a/features/createroom/impl/src/main/res/values-in/translations.xml
+++ b/features/createroom/impl/src/main/res/values-in/translations.xml
@@ -4,19 +4,15 @@
"Undang orang-orang"
"Terjadi kesalahan saat membuat ruangan"
"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."
- "Ruangan pribadi"
"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."
"Ruangan publik"
- "Siapa pun dapat bergabung dengan ruangan ini"
- "Siapa pun"
- "Akses Ruangan"
"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"
"Minta untuk bergabung"
+ "Siapa pun dapat bergabung dengan ruangan ini"
+ "Siapa pun"
"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."
"Alamat ruangan"
- "Nama ruangan"
"Keterlihatan ruangan"
- "Buat ruangan"
"Topik (opsional)"
diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml
index 741b88b763..03d4c140fc 100644
--- a/features/createroom/impl/src/main/res/values-it/translations.xml
+++ b/features/createroom/impl/src/main/res/values-it/translations.xml
@@ -4,19 +4,15 @@
"Invita persone"
"Si è verificato un errore durante la creazione della stanza"
"Solo le persone invitate possono accedere a questa stanza. Tutti i messaggi sono cifrati end-to-end."
- "Stanza privata"
"Chiunque può trovare questa stanza.
Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza."
"Stanza pubblica"
- "Chiunque può entrare in questa stanza"
- "Chiunque"
- "Accesso alla stanza"
"Chiunque può chiedere di entrare nella stanza, ma un amministratore o un moderatore dovrà accettare la richiesta"
"Chiedi di entrare"
+ "Chiunque può entrare in questa stanza"
+ "Chiunque"
"Affinché questa stanza sia visibile nell\'elenco delle stanze pubbliche, è necessario un indirizzo della stanza."
"Indirizzo della stanza"
- "Nome stanza"
"Visibilità della stanza"
- "Crea una stanza"
"Argomento (facoltativo)"
diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml
index 20c7af40de..22fc67afce 100644
--- a/features/createroom/impl/src/main/res/values-ka/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ka/translations.xml
@@ -4,10 +4,8 @@
"ხალხის მოწვევა"
"ოთახის შექმნისას შეცდომა მოხდა"
"ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია."
- "კერძო ოთახი"
"ყველას ამ ოთახის მოძებნა შეუძლია.
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."
- "ოთახის სახელი"
- "ოთახის შექმნა"
+ "საჯარო ოთახი"
"თემა (სურვილისამებრ)"
diff --git a/features/createroom/impl/src/main/res/values-ko/translations.xml b/features/createroom/impl/src/main/res/values-ko/translations.xml
index 3dfdc46e7c..b1cd90bbf1 100644
--- a/features/createroom/impl/src/main/res/values-ko/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ko/translations.xml
@@ -4,18 +4,14 @@
"사람 초대하기"
"방을 생성하던 중 오류가 발생했어요"
"초대받은 사람만 이 방에 액세스할 수 있습니다. 모든 메시지는 종단 간 암호화됩니다."
- "비공개 방"
"누구나 이 방을 찾을 수 있습니다.
방 설정에서 언제든지 변경할 수 있습니다."
- "공개 방"
- "누구나 이 방에 참여할 수 있습니다."
- "누구나"
- "방 액세스"
+ "공개 방 (모두)"
"누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다."
"참가 요청"
+ "누구나 이 방에 참여할 수 있습니다."
+ "누구나"
"이 방이 공개 방 디렉토리에 표시되려면 방 주소가 필요합니다."
- "방 이름"
"방 표시 여부"
- "방 만들기"
"주제 (선택)"
diff --git a/features/createroom/impl/src/main/res/values-lt/translations.xml b/features/createroom/impl/src/main/res/values-lt/translations.xml
index 2fb7b3eb5c..3d5566cb15 100644
--- a/features/createroom/impl/src/main/res/values-lt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-lt/translations.xml
@@ -4,10 +4,7 @@
"Pakviesti žmonių"
"Kuriant kambarį įvyko klaida"
"Į šį kambarį gali patekti tik pakviesti žmonės. Visi pranešimai yra užšifruoti nuo pradžios iki galo."
- "Privatus kambarys"
"Bet kas gali rasti šį kambarį.
Tai galite bet kada pakeisti kambario nustatymuose."
- "Kambario pavadinimas"
- "Kurti kambarį"
"Tema (nebūtina)"
diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml
index 9a15981112..e5ad63c84b 100644
--- a/features/createroom/impl/src/main/res/values-nb/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nb/translations.xml
@@ -4,18 +4,15 @@
"Inviter folk"
"Det oppsto en feil under opprettelsen av rommet"
"Bare inviterte personer har tilgang til dette rommet. Alle meldinger er ende-til-ende-kryptert."
- "Privat rom"
"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."
"Offentlig rom"
- "Alle kan bli med i dette rommet"
- "Alle"
- "Tilgang til rom"
"Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen"
"Be om å bli med"
+ "Alle kan bli med i dette rommet"
+ "Alle"
"For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse."
- "Romnavn"
+ "Romadresse"
"Romsynlighet"
- "Opprett et rom"
"Emne (valgfritt)"
diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml
index 5142148171..4691cb1f19 100644
--- a/features/createroom/impl/src/main/res/values-nl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nl/translations.xml
@@ -4,16 +4,12 @@
"Mensen uitnodigen"
"Er is een fout opgetreden bij het aanmaken van de kamer"
"Alleen uitgenodigde personen hebben toegang tot deze kamer. Alle berichten zijn end-to-end versleuteld."
- "Privé kamer"
"Iedereen kan deze kamer vinden.
Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."
"Openbare kamer"
- "Iedereen kan toetreden tot deze kamer"
- "Iedereen"
- "Toegang tot de kamer"
"Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren"
"Vraag om toe te treden"
- "Naam van de kamer"
- "Creëer een kamer"
+ "Iedereen kan toetreden tot deze kamer"
+ "Iedereen"
"Onderwerp (optioneel)"
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 446644b622..325d40cf5b 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -4,19 +4,15 @@
"Zaproś znajomych"
"Wystąpił błąd w trakcie tworzenia pokoju"
"Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end."
- "Pokój prywatny"
"Każdy może znaleźć ten pokój.
Możesz to zmienić w ustawieniach pokoju."
"Pokój publiczny"
- "Każdy może dołączyć do tego pokoju"
- "Wszyscy"
- "Dostęp do pokoju"
"Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"
"Poproś o dołączenie"
+ "Każdy może dołączyć do tego pokoju"
+ "Wszyscy"
"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."
"Adres pokoju"
- "Nazwa pokoju"
"Widoczność pomieszczenia"
- "Utwórz pokój"
"Temat (opcjonalnie)"
diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
index 399c9fec17..174f42db06 100644
--- a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,20 +3,21 @@
"Nova sala"
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
- "Apenas as pessoas convidadas podem entrar nesta sala. Todas as mensagens são criptografadas de ponta a ponta."
- "Sala privada"
+ "O espaço não pôde ser criado por conta de um erro desconhecido. Tente novamente mais tarde."
+ "Novo espaço"
+ "Apenas pessoas convidadas podem entrar."
+ "Privada"
"Qualquer um pode encontrar esta sala.
Você pode mudar isso a qualquer momento nas configurações da sala."
- "Sala pública"
- "Qualquer pessoa pode entrar nesta sala"
- "Qualquer pessoa"
- "Acesso à sala"
- "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação"
+ "Qualquer um pode entrar."
+ "Publica"
+ "Qualquer um pode pedir para entrar, mas um administrador ou moderador deve aceitar a solicitação"
"Pedir para entrar"
+ "Qualquer um pode entrar."
+ "Qualquer pessoa"
+ "Quem tem acesso"
"Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala."
"Endereço da sala"
- "Nome da sala"
"Visibilidade da sala"
- "Criar uma sala"
"Tópico (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml
index 1524914bb2..6225f531a5 100644
--- a/features/createroom/impl/src/main/res/values-pt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt/translations.xml
@@ -4,19 +4,15 @@
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta."
- "Sala privada"
"Qualquer um pode encontrar esta sala.
Pode alterar esta opção nas definições da sala."
"Sala pública"
- "Qualquer pessoa pode entrar nesta sala"
- "Qualquer pessoa"
- "Acesso à sala"
"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido"
"Pedir para participar"
+ "Qualquer pessoa pode entrar nesta sala"
+ "Qualquer pessoa"
"Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala."
"Endereço da sala"
- "Nome da sala"
"Visibilidade da sala"
- "Criar uma sala"
"Descrição (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml
index b9fe78bb19..01ce6bfa38 100644
--- a/features/createroom/impl/src/main/res/values-ro/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -4,19 +4,15 @@
"Invitați prieteni"
"A apărut o eroare la crearea camerei"
"Doar persoanele invitate pot accesa această cameră. Toate mesajele sunt criptate end-to-end."
- "Cameră privată"
"Oricine poate găsi această cameră.
Puteți modifica acest lucru oricând în setări."
"Cameră publică"
- "Oricine se poate alătura acestei camere"
- "Oricine"
- "Acces la cameră"
"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea"
"Cereți să vă alăturați"
+ "Oricine se poate alătura acestei camere"
+ "Oricine"
"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."
"Adresa camerei"
- "Numele camerei"
"Vizibilitatea camerei"
- "Creați o cameră"
"Subiect (opțional)"
diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml
index e871673114..4a009eaa10 100644
--- a/features/createroom/impl/src/main/res/values-ru/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ru/translations.xml
@@ -4,19 +4,15 @@
"Пригласить в комнату"
"Произошла ошибка при создании комнаты"
"Доступ в эту комнату имеют только приглашенные пользователи. Все сообщения защищены сквозным шифрованием."
- "Частная комната"
"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."
"Общедоступная комната"
- "Любой желающий может присоединиться к этой комнате"
- "Любой"
- "Доступ в комнату"
"Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."
"Попросить присоединиться"
+ "Любой желающий может присоединиться к этой комнате"
+ "Любой"
"Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"
"Адрес комнаты"
- "Название комнаты"
"Видимость комнаты"
- "Создать комнату"
"Тема (необязательно)"
diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml
index 7b6d89b2e1..555571fba9 100644
--- a/features/createroom/impl/src/main/res/values-sk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sk/translations.xml
@@ -4,19 +4,15 @@
"Pozvať ľudí"
"Pri vytváraní miestnosti došlo k chybe"
"Do tejto miestnosti majú prístup iba pozvaní ľudia. Všetky správy sú end-to-end šifrované."
- "Súkromná miestnosť"
"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."
"Verejná miestnosť"
- "Do tejto miestnosti sa môže pripojiť ktokoľvek"
- "Ktokoľvek"
- "Prístup do miestnosti"
"Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť"
"Požiadať o pripojenie"
+ "Do tejto miestnosti sa môže pripojiť ktokoľvek"
+ "Ktokoľvek"
"Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti."
"Adresa miestnosti"
- "Názov miestnosti"
"Viditeľnosť miestnosti"
- "Vytvoriť miestnosť"
"Téma (voliteľné)"
diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml
index 8cd01ebd0e..779bd893ea 100644
--- a/features/createroom/impl/src/main/res/values-sv/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sv/translations.xml
@@ -4,19 +4,15 @@
"Bjud in personer"
"Ett fel uppstod när rummet skapades"
"Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."
- "Privat rum"
"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."
"Offentligt rum"
- "Vem som helst kan gå med i det här rummet"
- "Vem som helst"
- "Rumsåtkomst"
"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"
"Be om att gå med"
+ "Vem som helst kan gå med i det här rummet"
+ "Vem som helst"
"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."
"Rumsadress"
- "Rumsnamn"
"Rumssynlighet"
- "Skapa ett rum"
"Ämne (valfritt)"
diff --git a/features/createroom/impl/src/main/res/values-tr/translations.xml b/features/createroom/impl/src/main/res/values-tr/translations.xml
index d97139c973..34406bb0fd 100644
--- a/features/createroom/impl/src/main/res/values-tr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-tr/translations.xml
@@ -4,19 +4,15 @@
"İnsanları davet et"
"Oda oluşturulurken bir hata oluştu"
"Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir."
- "Özel oda"
"Bu odayı herkes bulabilir.
Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz."
"Herkese açık oda"
- "Bu odaya herkes katılabilir"
- "Herkes"
- "Oda Erişimi"
"Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir"
"Katılmak için sor"
+ "Bu odaya herkes katılabilir"
+ "Herkes"
"Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır."
"Oda adresi"
- "Oda adı"
"Oda görünürlüğü"
- "Bir oda oluştur"
"Konu (isteğe bağlı)"
diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml
index 047b4dd9d2..e665d5c2f3 100644
--- a/features/createroom/impl/src/main/res/values-uk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uk/translations.xml
@@ -4,19 +4,15 @@
"Запросити людей"
"Під час створення кімнати сталася помилка"
"Лише запрошені люди мають доступ до цієї кімнати. Усі повідомлення захищені наскрізним шифруванням."
- "Приватна кімната (тільки за запрошенням)"
"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."
- "Загальнодоступна кімната"
- "Будь-хто може приєднатися до цієї кімнати"
- "Кожний"
- "Доступ до кімнати"
+ "Публічна кімната"
"Будь-хто може попросити приєднатися до кімнати, але адміністратор або модератор повинен буде прийняти запит"
"Запросити приєднатися"
+ "Будь-хто може приєднатися до цієї кімнати"
+ "Кожний"
"Щоб цю кімнату було видно в каталозі загальнодоступних кімнат, вам знадобиться її адреса."
"Адреса кімнати"
- "Назва кімнати"
"Видимість кімнати"
- "Створити кімнату"
"Тема (необов\'язково)"
diff --git a/features/createroom/impl/src/main/res/values-ur/translations.xml b/features/createroom/impl/src/main/res/values-ur/translations.xml
index b68992085f..ce1e23ffc9 100644
--- a/features/createroom/impl/src/main/res/values-ur/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ur/translations.xml
@@ -4,11 +4,7 @@
"لوگوں کو مدعو کریں"
"کمرہ تخلیق کرتے ہوئے ایک نقص واقع ہوا"
"صرف مدعو لوگ ہی اس کمرے تک رسائی حاصل کر سکتے ہیں۔ تمام پیغامات آخر تا آخر مرموز کردہ ہیں۔"
- "نجی کمرہ"
"کوئی بھی یہ کمرہ ڈھونڈ سکتا ہے۔
آپ اسے کمرے کی ترتیبات میں کسی بھی وقت تبدیل کرسکتے ہیں۔"
- "عوامی کمرہ"
- "کمرے کا نام"
- "ایک کمرہ بنائیں"
"موضوع (اختیاری)"
diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml
index 34062f9669..46905b5f1d 100644
--- a/features/createroom/impl/src/main/res/values-uz/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uz/translations.xml
@@ -4,18 +4,14 @@
"Odamlarni taklif qiling"
"Xonani yaratishda xatolik yuz berdi"
"Faqat taklif etilgan shaxslargina bu xonaga kira oladi. Barcha xabarlar boshdan-oxirigacha shifrlanadi."
- "Shaxsiy xona"
"Bu xonani har kim topishi mumkin.
Buni xona sozlamalaridan istalgan vaqtda oʻzgartirishingiz mumkin."
- "Jamoat xonasi"
- "Bu xonaga istalgan kishi qo‘shilishi mumkin"
- "Har kim"
- "Xonaga kirish"
+ "Jamoat xonasi (har kim)"
"Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak"
"Qo‘shilishni so‘rang"
+ "Bu xonaga istalgan kishi qo‘shilishi mumkin"
+ "Har kim"
"Ushbu xona ommaviy xonalar ro‘yxatida ko‘rinishi uchun sizga xona manzili kerak bo‘ladi."
- "Xona nomi"
"Xonaning ko‘rinishi"
- "Xonani yaratish"
"Mavzu (ixtiyoriy)"
diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
index 476f9cff7e..595b06d55e 100644
--- a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
@@ -4,19 +4,15 @@
"邀請夥伴"
"建立聊天室時發生錯誤"
"僅被邀請的人才能存取此聊天室。所有訊息均會端到端加密。"
- "私密聊天室"
"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"
- "公開的聊天室"
- "任何人都可以加入此聊天室"
- "任何人"
- "聊天室存取權"
+ "公開聊天室"
"任何人都可以要求加入聊天室,但管理員或版主必須接受該請求"
"要求加入"
+ "任何人都可以加入此聊天室"
+ "任何人"
"為了讓此聊天室在公開聊天室目錄中可見,您需要聊天室地址。"
"聊天室地址"
- "聊天室名稱"
"聊天室能見度"
- "建立聊天室"
"主題(非必填)"
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 d20e34801d..1a189884cf 100644
--- a/features/createroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh/translations.xml
@@ -4,19 +4,15 @@
"邀请朋友"
"创建聊天室时出错"
"只有受邀用户才能访问此聊天室。所有消息均经过端到端加密。"
- "私有聊天室"
"任何人都能找到此聊天室。
你可以随时在聊天室设置中更改。"
- "公共聊天室"
- "任何人都可以加入此房间"
- "任何人"
- "房间访问权限"
+ "公开聊天室"
"任何人都可以请求加入房间,但必须由管理员或审核人接受"
"请求加入"
+ "任何人都可以加入此房间"
+ "任何人"
"要使该房间在公开房间目录中可见,您需要一个房间地址。"
"房间地址"
- "聊天室名称"
"房间可见性"
- "创建聊天室"
"主题(可选)"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index fa9a1cb276..3ec2d9d6f3 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -3,20 +3,26 @@
"New room"
"Invite people"
"An error occurred when creating the room"
- "Only people invited can access this room. All messages are end-to-end encrypted."
- "Private room"
+ "The space could not be created because of an unknown error. Try again later."
+ "Add name…"
+ "New room"
+ "New space"
+ "Only people invited can join."
+ "Private"
"Anyone can find this room.
You can change this anytime in room settings."
- "Public room"
- "Anyone can join this room"
- "Anyone"
- "Room Access"
- "Anyone can ask to join the room but an administrator or a moderator will have to accept the request"
- "Ask to join"
- "In order for this room to be visible in the public room directory, you will need a room address."
- "Room address"
- "Room name"
+ "Anyone can join."
+ "Public"
+ "Anyone can ask to join but an administrator or a moderator must accept the request."
+ "Allow ask to join"
+ "Only people invited can join."
+ "Private"
+ "Anyone can join."
+ "Public"
+ "Who has access"
+ "You’ll need an address in order to make it visible in the public directory."
+ "Address"
"Room visibility"
- "Create a room"
"Topic (optional)"
+ "Add description…"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
index 35b6637bbf..5b7a6c1142 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
@@ -40,6 +40,7 @@ class DefaultCreateRoomEntryPointTest {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.createNode(
+ isSpace = false,
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
index c8a6c2bd8a..e82d8c3912 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
@@ -51,15 +51,10 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
-import io.mockk.every
import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -76,17 +71,6 @@ class ConfigureRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- @Before
- fun setup() {
- mockkStatic(File::readBytes)
- every { any().readBytes() } returns byteArrayOf()
- }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
@Test
fun `present - initial state`() = runTest {
val presenter = createConfigureRoomPresenter()
@@ -261,20 +245,25 @@ class ConfigureRoomPresenterTest {
val initialState = initialState()
dataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1)
- mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
- matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
+ val file = File.createTempFile("test", "jpg")
+ try {
+ mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk())))
+ matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterCreateRoom = awaitItem()
- assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
- matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ } finally {
+ file.delete()
+ }
}
}
@@ -391,6 +380,7 @@ class ConfigureRoomPresenterTest {
)
private fun createConfigureRoomPresenter(
+ isSpace: Boolean = false,
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
@@ -401,6 +391,7 @@ class ConfigureRoomPresenterTest {
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
+ isSpace = isSpace,
dataStore = dataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
index 2beaecf013..bbeb69c26b 100644
--- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
+++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
@@ -14,6 +14,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
diff --git a/features/deactivation/impl/src/main/res/values-hr/translations.xml b/features/deactivation/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..04148fdc48
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Potvrdite da želite deaktivirati svoj račun. Ova se radnja ne može poništiti."
+ "Izbriši sve moje poruke"
+ "Upozorenje: budući korisnici mogu vidjeti nepotpune razgovore."
+ "Deaktiviranje vašeg računa je %1$s, to će:"
+ "nepovratno"
+ "%1$s vaš račun (ne možete se ponovno prijaviti i vaš ID se ne može ponovno upotrijebiti)."
+ "Trajno onemogući"
+ "Ukloniti vas iz svih soba za razgovore."
+ "Izbrisati podatke o vašem računu s našeg poslužitelja identiteta."
+ "Vaše će poruke i dalje biti vidljive registriranim korisnicima, ali neće biti dostupne novim ili neregistriranim korisnicima ako ih odlučite izbrisati."
+ "Deaktiviraj račun"
+
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
index 344d83c38f..deb7fc59b9 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
@@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@@ -58,7 +58,7 @@ class NotificationsOptInPresenter(
if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
- notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ notificationsPermissionsState.eventSink(PermissionsEvent.RequestPermissions)
}
}
NotificationsOptInEvents.NotNowClicked -> {
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
index e4e922f933..8f72f038de 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
@@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons(
AsyncData.Uninitialized,
is AsyncData.Failure,
is AsyncData.Loading -> {
- Button(
- modifier = Modifier.fillMaxWidth(),
- enabled = false,
- showProgress = true,
- text = stringResource(CommonStrings.common_loading),
- onClick = {},
- )
+ LoadingButtonAtom()
}
is AsyncData.Success -> {
if (state.buttonsState.data.canUseAnotherDevice) {
diff --git a/features/ftue/impl/src/main/res/values-hr/translations.xml b/features/ftue/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..cb0e330872
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Ne možete potvrditi?"
+ "Izradi novi ključ za oporavak"
+ "Potvrdite ovaj uređaj kako biste postavili sigurnu razmjenu poruka."
+ "Potvrdite svoj identitet"
+ "Upotrijebite drugi uređaj"
+ "Upotrijebi ključ za oporavak"
+ "Sada možete sigurno čitati ili slati poruke, a svatko s kim razgovarate također može vjerovati ovom uređaju."
+ "Uređaj je potvrđen"
+ "Upotrijebite drugi uređaj"
+ "Čekanje na drugi uređaj…"
+ "Postavke možete promijeniti poslije."
+ "Omogućite obavijesti i nikada ne propustite poruku"
+ "Unesi ključ za oporavak"
+
diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
index 71ee093985..1a90245611 100644
--- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
+++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
@@ -25,6 +25,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom()
+ fun navigateToCreateSpace()
fun navigateToSettings()
fun navigateToSetUpRecovery()
fun navigateToEnterRecoveryKey()
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
index d9f87e2edd..0e92761bd5 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -220,6 +220,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom,
+ onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey,
onRoomSettingsClick = callback::navigateToRoomSettings,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt
index 6a4a5e1688..035e953b23 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt
@@ -28,7 +28,7 @@ enum class HomeNavigationBarItem(
isSelected: Boolean,
) = when (this) {
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
- Spaces -> if (isSelected) CompoundIcons.WorkspaceSolid() else CompoundIcons.Workspace()
+ Spaces -> if (isSelected) CompoundIcons.SpaceSolid() else CompoundIcons.Space()
}
companion object {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
index e53d20857e..3f223135c1 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
@@ -28,8 +28,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -48,7 +46,6 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter,
private val logoutPresenter: Presenter,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
- private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
) : Presenter {
@@ -69,9 +66,6 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
- val isSpaceFeatureEnabled by remember {
- featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
- }.collectAsState(initial = false)
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -117,7 +111,6 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
- isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = ::handleEvent,
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
index 90667a8734..474fb6d5ba 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
@@ -29,10 +29,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
- val isSpaceFeatureEnabled: Boolean,
val eventSink: (HomeEvents) -> Unit,
) {
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
- val showNavigationBar = isSpaceFeatureEnabled && homeSpacesState.spaceRooms.isNotEmpty()
+ val showNavigationBar = homeSpacesState.spaceRooms.isNotEmpty()
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
index 43010b1720..e68ff7aa1f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
@@ -31,7 +31,6 @@ open class HomeStateProvider : PreviewParameterProvider {
aHomeState(hasNetworkConnection = false),
aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aHomeState(
- isSpaceFeatureEnabled = true,
roomListState = aRoomListState(
// Add more rooms to see the blur effect under the NavigationBar
contentState = aRoomsContentState(
@@ -42,7 +41,6 @@ open class HomeStateProvider : PreviewParameterProvider {
homeSpacesState = aHomeSpacesState(),
),
aHomeState(
- isSpaceFeatureEnabled = true,
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
) + RoomListStateProvider().values.map {
@@ -60,7 +58,6 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
- isSpaceFeatureEnabled: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {}
) = HomeState(
@@ -73,6 +70,5 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
- isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
index 42e4f72073..f55183feb0 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
@@ -74,6 +74,7 @@ fun HomeView(
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
+ onCreateSpaceClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
@@ -113,6 +114,7 @@ fun HomeView(
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
+ onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
)
// This overlaid view will only be visible when state.displaySearchResults is true
@@ -138,6 +140,7 @@ private fun HomeScaffold(
onRoomClick: (RoomId) -> Unit,
onOpenSettings: () -> Unit,
onStartChatClick: () -> Unit,
+ onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -164,6 +167,7 @@ private fun HomeScaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
HomeTopBar(
+ selectedNavigationItem = state.currentHomeNavigationBarItem,
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
@@ -174,19 +178,16 @@ private fun HomeScaffold(
onAccountSwitch = {
state.eventSink(HomeEvents.SwitchToAccount(it))
},
+ onCreateSpace = onCreateSpaceClick,
scrollBehavior = scrollBehavior,
- displayMenuItems = state.displayActions,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
+ canCreateSpaces = state.homeSpacesState.canCreateSpaces,
canReportBug = state.canReportBug,
- modifier = if (state.isSpaceFeatureEnabled) {
- Modifier.hazeEffect(
- state = hazeState,
- style = HazeMaterials.thick(),
- )
- } else {
- Modifier.background(ElementTheme.colors.bgCanvasDefault)
- }
+ modifier = Modifier.hazeEffect(
+ state = hazeState,
+ style = HazeMaterials.thick(),
+ )
)
},
bottomBar = {
@@ -332,6 +333,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
+ onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
@@ -351,6 +353,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
+ onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
index 093b91fb66..c92a5b9fb8 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.HomeNavigationBarItem
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
@@ -73,6 +74,7 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTopBar(
+ selectedNavigationItem: HomeNavigationBarItem,
title: String,
currentUserAndNeighbors: ImmutableList,
showAvatarIndicator: Boolean,
@@ -81,8 +83,9 @@ fun HomeTopBar(
onMenuActionClick: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
onAccountSwitch: (SessionId) -> Unit,
+ onCreateSpace: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
- displayMenuItems: Boolean,
+ canCreateSpaces: Boolean,
canReportBug: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
@@ -117,63 +120,16 @@ fun HomeTopBar(
)
},
actions = {
- if (displayMenuItems) {
- IconButton(
- onClick = onToggleSearch,
- ) {
- Icon(
- imageVector = CompoundIcons.Search(),
- contentDescription = stringResource(CommonStrings.action_search),
- )
- }
- if (RoomListConfig.HAS_DROP_DOWN_MENU) {
- var showMenu by remember { mutableStateOf(false) }
- IconButton(
- onClick = { showMenu = !showMenu }
- ) {
- Icon(
- imageVector = CompoundIcons.OverflowVertical(),
- contentDescription = null,
- )
- }
- DropdownMenu(
- expanded = showMenu,
- onDismissRequest = { showMenu = false }
- ) {
- if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClick(RoomListMenuAction.InviteFriends)
- },
- text = { Text(stringResource(id = CommonStrings.action_invite)) },
- leadingIcon = {
- Icon(
- imageVector = CompoundIcons.ShareAndroid(),
- tint = ElementTheme.colors.iconSecondary,
- contentDescription = null,
- )
- }
- )
- }
- if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClick(RoomListMenuAction.ReportBug)
- },
- text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
- leadingIcon = {
- Icon(
- imageVector = CompoundIcons.ChatProblem(),
- tint = ElementTheme.colors.iconSecondary,
- contentDescription = null,
- )
- }
- )
- }
- }
- }
+ when (selectedNavigationItem) {
+ HomeNavigationBarItem.Chats -> RoomListMenuItems(
+ onToggleSearch = onToggleSearch,
+ onMenuActionClick = onMenuActionClick,
+ canReportBug = canReportBug
+ )
+ HomeNavigationBarItem.Spaces -> SpacesMenuItems(
+ canCreateSpaces = canCreateSpaces,
+ onCreateSpace = onCreateSpace
+ )
}
},
// We want a 16dp left padding for the navigationIcon :
@@ -193,6 +149,85 @@ fun HomeTopBar(
}
}
+@Composable
+private fun RoomListMenuItems(
+ onToggleSearch: () -> Unit,
+ onMenuActionClick: (RoomListMenuAction) -> Unit,
+ canReportBug: Boolean,
+) {
+ IconButton(
+ onClick = onToggleSearch,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Search(),
+ contentDescription = stringResource(CommonStrings.action_search),
+ )
+ }
+ if (RoomListConfig.HAS_DROP_DOWN_MENU) {
+ var showMenu by remember { mutableStateOf(false) }
+ IconButton(
+ onClick = { showMenu = !showMenu }
+ ) {
+ Icon(
+ imageVector = CompoundIcons.OverflowVertical(),
+ contentDescription = null,
+ )
+ }
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClick(RoomListMenuAction.InviteFriends)
+ },
+ text = { Text(stringResource(id = CommonStrings.action_invite)) },
+ leadingIcon = {
+ Icon(
+ imageVector = CompoundIcons.ShareAndroid(),
+ tint = ElementTheme.colors.iconSecondary,
+ contentDescription = null,
+ )
+ }
+ )
+ }
+ if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClick(RoomListMenuAction.ReportBug)
+ },
+ text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
+ leadingIcon = {
+ Icon(
+ imageVector = CompoundIcons.ChatProblem(),
+ tint = ElementTheme.colors.iconSecondary,
+ contentDescription = null,
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpacesMenuItems(
+ canCreateSpaces: Boolean,
+ onCreateSpace: () -> Unit
+) {
+ if (canCreateSpaces) {
+ IconButton(onClick = onCreateSpace) {
+ Icon(
+ imageVector = CompoundIcons.Plus(),
+ contentDescription = stringResource(CommonStrings.action_create_space)
+ )
+ }
+ }
+}
+
@Composable
private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList,
@@ -273,6 +308,7 @@ private fun AccountIcon(
@Composable
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
@@ -281,7 +317,8 @@ internal fun HomeTopBarPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@@ -289,11 +326,35 @@ internal fun HomeTopBarPreview() = ElementPreview {
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewsDayNight
+@Composable
+internal fun HomeTopBarSpacesPreview() = ElementPreview {
+ HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Spaces,
+ title = stringResource(R.string.screen_home_tab_spaces),
+ currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ showAvatarIndicator = false,
+ areSearchResultsDisplayed = false,
+ scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
+ onOpenSettings = {},
+ onAccountSwitch = {},
+ onToggleSearch = {},
+ onCreateSpace = {},
+ canCreateSpaces = true,
+ canReportBug = true,
+ displayFilters = false,
+ filtersState = aRoomListFiltersState(),
+ onMenuActionClick = {},
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
@@ -302,7 +363,8 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@@ -315,6 +377,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
@Composable
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false,
@@ -323,7 +386,8 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index da0f47ba05..50b9559e94 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -121,7 +121,6 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
- latestEvent = room.latestEvent,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
@@ -138,7 +137,6 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
- latestEvent = room.latestEvent,
timestamp = null,
isHighlighted = room.isHighlighted
)
@@ -214,7 +212,6 @@ private fun RoomSummaryScaffoldRow(
@Composable
private fun NameAndTimestampRow(
name: String?,
- latestEvent: LatestEvent,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
@@ -236,28 +233,6 @@ private fun NameAndTimestampRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- // Picto
- when (latestEvent) {
- is LatestEvent.Sending -> {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- modifier = Modifier.size(16.dp),
- imageVector = CompoundIcons.Time(),
- contentDescription = null,
- tint = ElementTheme.colors.iconTertiary,
- )
- }
- is LatestEvent.Error -> {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- modifier = Modifier.size(16.dp),
- imageVector = CompoundIcons.ErrorSolid(),
- contentDescription = null,
- tint = ElementTheme.colors.iconCriticalPrimary,
- )
- }
- else -> Unit
- }
}
// Timestamp
Text(
@@ -302,7 +277,6 @@ private fun MessagePreviewAndIndicatorRow(
) {
Row(
modifier = modifier.fillMaxWidth(),
- horizontalArrangement = spacedBy(28.dp)
) {
if (room.isTombstoned) {
Text(
@@ -316,6 +290,16 @@ private fun MessagePreviewAndIndicatorRow(
)
} else {
if (room.latestEvent is LatestEvent.Error) {
+ Icon(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(16.dp),
+ imageVector = CompoundIcons.ErrorSolid(),
+ // The last message contains the error.
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
Text(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.common_message_failed_to_send),
@@ -326,6 +310,17 @@ private fun MessagePreviewAndIndicatorRow(
overflow = TextOverflow.Ellipsis,
)
} else {
+ if (room.latestEvent is LatestEvent.Sending) {
+ Icon(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(16.dp),
+ imageVector = CompoundIcons.Time(),
+ contentDescription = stringResource(CommonStrings.common_sending),
+ tint = ElementTheme.colors.iconTertiary,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ }
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text(
@@ -339,7 +334,7 @@ private fun MessagePreviewAndIndicatorRow(
)
}
}
-
+ Spacer(modifier = Modifier.width(16.dp))
// Call and unread
Row(
modifier = Modifier
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
index ef5d1ffcc6..c17923fb04 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -31,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
-import timber.log.Timber
+import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds
@Inject
@@ -43,6 +44,7 @@ class RoomListDataSource(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
+ private val analyticsService: AnalyticsService,
) {
init {
observeNotificationSettings()
@@ -139,10 +141,18 @@ class RoomListDataSource(
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
if (duplicates.isNotEmpty()) {
- Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind")
- }
+ analyticsService.trackError(
+ IllegalStateException(
+ "Found duplicates in room summaries after a local UI update: $duplicates. " +
+ "This could be a race condition/caching issue of some kind"
+ )
+ )
- _allRooms.emit(roomListRoomSummaries.toImmutableList())
+ // Remove duplicates before emitting the new values
+ _allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
+ } else {
+ _allRooms.emit(roomListRoomSummaries.toImmutableList())
+ }
}
private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
index 188f4468de..7f58e05c7e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
@@ -91,7 +91,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList {
timestamp = "14:18",
latestEvent = LatestEvent.Synced("A very very very very long message which suites on two lines"),
avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
- id = "!roomId:domain",
+ id = "!roomId5:domain",
),
aRoomListRoomSummary(
name = "Room#2",
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
index 20222c144d..d8269fbc04 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
@@ -10,6 +10,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
- data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
index ad06b12f5e..49047ffaed 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -29,29 +31,24 @@ class RoomListSearchPresenter(
var isSearchActive by remember {
mutableStateOf(false)
}
- var searchQuery by remember {
- mutableStateOf("")
- }
+ val searchQuery = rememberTextFieldState()
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
- LaunchedEffect(searchQuery) {
- dataSource.setSearchQuery(searchQuery)
+ LaunchedEffect(searchQuery.text) {
+ dataSource.setSearchQuery(searchQuery.text.toString())
}
fun handleEvent(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
- searchQuery = ""
- }
- is RoomListSearchEvents.QueryChanged -> {
- searchQuery = event.query
+ searchQuery.clearText()
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
- searchQuery = ""
+ searchQuery.clearText()
}
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
index 92e8c1ff03..c2d889388b 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
@@ -8,12 +8,13 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val isSearchActive: Boolean,
- val query: String,
+ val query: TextFieldState,
val results: ImmutableList,
val eventSink: (RoomListSearchEvents) -> Unit
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
index a5015a5003..645eb791ba 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
@@ -8,6 +8,7 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
@@ -33,7 +34,7 @@ fun aRoomListSearchState(
eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
- query = query,
+ query = TextFieldState(initialText = query),
results = results,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
index 58d6ba7e00..f013b602dd 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
@@ -18,16 +18,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
@@ -35,7 +32,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -112,23 +108,14 @@ private fun RoomListSearchContent(
},
navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = {
- // TODO replace `state.query` with TextFieldState when it's available for M3 TextField
// The stateSaver will keep the selection state when returning to this UI
- var value by rememberSaveable(stateSaver = TextFieldValue.Saver) {
- mutableStateOf(TextFieldValue(state.query))
- }
-
val focusRequester = remember { FocusRequester() }
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
- value = value,
- singleLine = true,
- onValueChange = {
- value = it
- state.eventSink(RoomListSearchEvents.QueryChanged(it.text))
- },
+ state = state.query,
+ lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
@@ -138,20 +125,18 @@ private fun RoomListSearchContent(
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
- trailingIcon = {
- if (value.text.isNotEmpty()) {
- IconButton(onClick = {
- state.eventSink(RoomListSearchEvents.ClearQuery)
- // Clear local state too
- value = value.copy(text = "")
- }) {
+ trailingIcon = if (state.query.text.isNotEmpty()) {
+ @Composable {
+ IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
)
}
}
- }
+ } else {
+ null
+ },
)
LaunchedEffect(Unit) {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
index a890a61ac3..00129235fe 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.invite.api.SeenInvitesStore
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.MatrixClient
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentListOf
@@ -27,9 +29,11 @@ import kotlinx.coroutines.flow.map
class HomeSpacesPresenter(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
+ private val featureFlagsService: FeatureFlagService,
) : Presenter {
@Composable
override fun present(): HomeSpacesState {
+ val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember {
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
@@ -48,6 +52,7 @@ class HomeSpacesPresenter(
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
+ canCreateSpaces = canCreateSpaces,
eventSink = ::handleEvent,
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
index 7dcb370219..9bcf7131c8 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
@@ -18,6 +18,7 @@ data class HomeSpacesState(
val spaceRooms: ImmutableList,
val seenSpaceInvites: ImmutableSet,
val hideInvitesAvatar: Boolean,
+ val canCreateSpaces: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
index 8c03cff7ee..c1a32a1f34 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
@@ -30,6 +30,13 @@ open class HomeSpacesStateProvider : PreviewParameterProvider {
),
spaceRooms = aListOfSpaceRooms(),
),
+ aHomeSpacesState(
+ space = CurrentSpace.Space(
+ spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
+ ),
+ spaceRooms = aListOfSpaceRooms(),
+ canCreateSpaces = false,
+ ),
)
}
@@ -38,12 +45,14 @@ internal fun aHomeSpacesState(
spaceRooms: List = aListOfSpaceRooms(),
seenSpaceInvites: Set = emptySet(),
hideInvitesAvatar: Boolean = false,
+ canCreateSpaces: Boolean = true,
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
space = space,
spaceRooms = spaceRooms.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
+ canCreateSpaces = canCreateSpaces,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/res/values-hr/translations.xml b/features/home/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..233cac78d8
--- /dev/null
+++ b/features/home/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,55 @@
+
+
+ "Onemogućite optimizaciju baterije za ovu aplikaciju kako biste bili sigurni da ćete primati sve obavijesti."
+ "Onemogući optimizaciju"
+ "Obavijesti ne stižu?"
+ "Vaš je signal obavijesti ažuriran – jasniji je, brži i manje ometajući."
+ "Ažurirali smo vaše zvukove"
+ "Ako ste izgubili sve postojeće uređaje, oporavite svoj kriptografski identitet i povijest poruka pomoću ključa za oporavak."
+ "Postavljanje oporavka"
+ "Postavite oporavak kako biste zaštitili svoj račun"
+ "Potvrdite svoj ključ za oporavak kako biste zadržali pristup pohrani ključeva i povijesti poruka."
+ "Unesite svoj ključ za oporavak"
+ "Zaboravili ste ključ za oporavak?"
+ "Vaša pohrana ključeva nije sinkronizirana"
+ "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan."
+ "Poboljšajte svoje iskustvo poziva"
+ "Razgovori"
+ "Prostori"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?"
+ "Odbij poziv"
+ "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?"
+ "Odbij razgovor"
+ "Nema pozivnica"
+ "Pozvao vas je korisnik %1$s (%2$s)"
+ "Ovaj se postupak izvodi samo jednom, hvala na čekanju."
+ "Postavljanje vašeg računa."
+ "Stvori novi razgovor ili sobu"
+ "Ukloni filtre"
+ "Započnite tako da nekome pošaljete poruku."
+ "Još nema razgovora."
+ "Favoriti"
+ "Razgovor možete dodati u favorite u postavkama razgovora.
+Zasad možete poništiti odabir filtara kako biste vidjeli ostale razgovore."
+ "Još nemate omiljenih razgovora"
+ "Pozivnice"
+ "Nemate pozivnica na čekanju."
+ "Nizak prioritet"
+ "Još nemate razgovora niskog prioriteta"
+ "Možete poništiti odabir filtara kako biste vidjeli ostale razgovore"
+ "Nemate razgovora za ovaj odabir"
+ "Osobe"
+ "Nemate još nijednu izravnu poruku"
+ "Sobe"
+ "Niste još ni u jednoj sobi"
+ "Nepročitano"
+ "Čestitamo!
+Nemate nepročitanih poruka!"
+ "Zahtjev za pridruživanje je poslan"
+ "Razgovori"
+ "Označi kao pročitano"
+ "Označi kao nepročitano"
+ "Ova je soba nadograđena"
+ "Izgleda da koristite novi uređaj. Izvršite provjeru drugim uređajem da biste pristupili svojim šifriranim porukama."
+ "Potvrdi identitet"
+
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 9778556dd2..582de66414 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -47,6 +47,7 @@ class DefaultHomeEntryPointTest {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
+ override fun navigateToCreateSpace() = lambdaError()
override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = lambdaError()
override fun navigateToEnterRecoveryKey() = lambdaError()
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
index 0ae3ea1ff3..266c33015d 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
@@ -22,9 +22,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
@@ -35,7 +32,6 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@@ -54,8 +50,6 @@ class HomePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta())
-
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val matrixClient = FakeMatrixClient(
@@ -79,7 +73,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null)
@@ -91,8 +84,7 @@ class HomePresenterTest {
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
)
assertThat(withUserState.showAvatarIndicator).isFalse()
- assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled)
- assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled)
+ assertThat(withUserState.showNavigationBar).isTrue()
}
}
@@ -114,23 +106,6 @@ class HomePresenterTest {
}
}
- @Test
- fun `present - space feature enabled`() = runTest {
- val presenter = createHomePresenter(
- featureFlagService = FakeFeatureFlagService(
- initialState = mapOf(FeatureFlags.Space.key to true),
- ),
- sessionStore = InMemorySessionStore(
- updateUserProfileResult = { _, _, _ -> },
- ),
- )
- presenter.test {
- skipItems(1)
- val initialState = awaitItem()
- assertThat(initialState.isSpaceFeatureEnabled).isTrue()
- }
- }
-
@Test
fun `present - show avatar indicator`() = runTest {
val indicatorService = FakeIndicatorService()
@@ -143,7 +118,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true)
@@ -168,7 +142,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
@@ -189,7 +162,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
@@ -207,16 +179,12 @@ class HomePresenterTest {
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
- featureFlagService = FakeFeatureFlagService(
- initialState = mapOf(FeatureFlags.Space.key to true),
- ),
homeSpacesPresenter = homeSpacesPresenter,
announcementService = FakeAnnouncementService(
showAnnouncementResult = {},
)
)
presenter.test {
- skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue()
@@ -241,7 +209,6 @@ internal fun createHomePresenter(
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
@@ -250,11 +217,10 @@ internal fun createHomePresenter(
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
- logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
+ logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
- featureFlagService = featureFlagService,
sessionStore = sessionStore,
announcementService = announcementService,
)
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
index 35c30af86e..aaecd90c56 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -103,5 +104,6 @@ class RoomListDataSourceTest {
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
+ analyticsService = FakeAnalyticsService(),
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index acc68db018..a97c189947 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -662,6 +662,7 @@ class RoomListPresenterTest {
notificationSettingsService = client.notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
+ analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index bb82d51a79..a07093aa9f 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -273,6 +273,7 @@ private fun AndroidComposeTestRule.setRoomL
onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
+ onCreateSpaceClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
@@ -286,6 +287,7 @@ private fun AndroidComposeTestRule.setRoomL
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
+ onCreateSpaceClick = onCreateSpaceClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
index dee0601915..0fd6459057 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
@@ -33,7 +33,7 @@ class RoomListSearchPresenterTest {
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
- assertThat(state.query).isEmpty()
+ assertThat(state.query.text.toString()).isEmpty()
assertThat(state.results).isEmpty()
}
}
@@ -72,10 +72,10 @@ class RoomListSearchPresenterTest {
).isEqualTo(
RoomListFilter.None
)
- state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
+ state.query.edit { append("Search") }
}
awaitItem().let { state ->
- assertThat(state.query).isEqualTo("Search")
+ assertThat(state.query.text).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
@@ -84,7 +84,7 @@ class RoomListSearchPresenterTest {
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
- assertThat(state.query).isEmpty()
+ assertThat(state.query.text.toString()).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
index c7608833ac..43d3a8896d 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.spaces
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.test.InMemorySeenInvitesStore
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
@@ -23,18 +26,25 @@ class HomeSpacesPresenterTest {
val presenter = createPresenter()
presenter.test {
val state = awaitItem()
+ // canCreateSpaces is initially false
+ assertThat(state.canCreateSpaces).isFalse()
assertThat(state.space).isEqualTo(CurrentSpace.Root)
assertThat(state.spaceRooms).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.seenSpaceInvites).isEmpty()
+
+ // It'll eventually be true
+ assertThat(awaitItem().canCreateSpaces).isTrue()
}
}
private fun createPresenter(
client: MatrixClient = FakeMatrixClient(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
+ featureFlagsService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.CreateSpaces.key to true)),
) = HomeSpacesPresenter(
client = client,
seenInvitesStore = seenInvitesStore,
+ featureFlagsService = featureFlagsService,
)
}
diff --git a/features/invite/impl/src/main/res/values-hr/translations.xml b/features/invite/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..6d853dacf1
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Nećete vidjeti nikakve poruke ili pozivnice za sobu od ovog korisnika"
+ "Blokiraj korisnika"
+ "Prijavite ovu sobu svom davatelju usluga računa."
+ "Navedite razlog prijave…"
+ "Odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?"
+ "Odbij poziv"
+ "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?"
+ "Odbij razgovor"
+ "Nema pozivnica"
+ "Pozvao vas je korisnik %1$s (%2$s)"
+ "Da, odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe."
+ "Odbij poziv i blokiraj"
+ "Odbij i blokiraj"
+
diff --git a/features/invitepeople/impl/src/main/res/values-hr/translations.xml b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..66031c5fd7
--- /dev/null
+++ b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Već je član"
+ "Već je pozvan/a"
+
diff --git a/features/joinroom/impl/src/main/res/values-hr/translations.xml b/features/joinroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..1a8489255a
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,34 @@
+
+
+ "Korisnik %1$s vam je zabranio pristup."
+ "Zabranjen vam je pristup"
+ "Razlog: %1$s."
+ "Otkaži zahtjev"
+ "Da, otkaži"
+ "Jeste li sigurni da želite otkazati svoj zahtjev za pridruživanje ovoj sobi?"
+ "Otkaži zahtjev za pridruživanje"
+ "Da, odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe."
+ "Odbij poziv i blokiraj"
+ "Odbij i blokiraj"
+ "Pridruživanje nije uspjelo"
+ "Morate biti pozvani da se pridružite ili možda postoje ograničenja pristupa."
+ "Zaboravi"
+ "Trebate imati pozivnicu kako biste se pridružili"
+ "Pozvao/la"
+ "Pridruži se"
+ "Možda ćete morati biti pozvani ili biti član prostora kako biste se pridružili."
+ "Pošalji zahtjev za pridruživanje"
+ "Dopuštenih znakova %1$d od %2$d"
+ "Poruka (nije obavezna)"
+ "Primit ćete pozivnicu za pridruživanje sobi ako vaš zahtjev bude prihvaćen."
+ "Zahtjev za pridruživanje je poslan"
+ "Nismo mogli prikazati pregled sobe. To bi moglo biti zbog problema s mrežom ili poslužiteljem."
+ "Nismo mogli prikazati pregled ove sobe"
+ "%1$s još ne podržava prostore. Prostorima možete pristupiti na internetu."
+ "Prostori još nisu podržani"
+ "Kliknite donji gumb i administrator sobe dobit će obavijest. Moći ćete se pridružiti razgovoru nakon što dobijete odobrenje."
+ "Morate biti član ove sobe da biste vidjeli povijest poruka."
+ "Želite li se pridružiti ovoj sobi?"
+ "Pregled nije dostupan"
+
diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt
new file mode 100644
index 0000000000..82bceb5be0
--- /dev/null
+++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.knockrequests.api
+
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
+data class KnockRequestPermissions(
+ val canAccept: Boolean,
+ val canDecline: Boolean,
+ val canBan: Boolean,
+) {
+ val hasAny = canAccept || canDecline || canBan
+
+ companion object {
+ val DEFAULT = KnockRequestPermissions(
+ canAccept = false,
+ canDecline = false,
+ canBan = false,
+ )
+ }
+}
+
+fun RoomPermissions.knockRequestPermissions(): KnockRequestPermissions {
+ return KnockRequestPermissions(
+ canAccept = canOwnUserInvite(),
+ canDecline = canOwnUserKick(),
+ canBan = canOwnUserBan(),
+ )
+}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
index 641efffaa3..f340d597b1 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
@@ -49,7 +49,7 @@ class KnockRequestsBannerPresenter(
val shouldShowBanner by remember {
derivedStateOf {
- permissions.canHandle && knockRequests.isNotEmpty()
+ permissions.hasAny && knockRequests.isNotEmpty()
}
}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt
deleted file mode 100644
index 2ca4d4df74..0000000000
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.knockrequests.impl.data
-
-import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.powerlevels.canBan
-import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
-import io.element.android.libraries.matrix.api.room.powerlevels.canKick
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-
-data class KnockRequestPermissions(
- val canAccept: Boolean,
- val canDecline: Boolean,
- val canBan: Boolean,
-) {
- val canHandle = canAccept || canDecline || canBan
-}
-
-fun JoinedRoom.knockRequestPermissionsFlow(): Flow {
- return syncUpdateFlow.map {
- val canAccept = canInvite().getOrDefault(false)
- val canDecline = canKick().getOrDefault(false)
- val canBan = canBan().getOrDefault(false)
- KnockRequestPermissions(canAccept, canDecline, canBan)
- }
-}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
index eeba54f87d..b51b78f105 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
@@ -12,10 +12,13 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.knockRequestPermissions
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
@BindingContainer
@ContributesTo(RoomScope::class)
@@ -25,7 +28,9 @@ object KnockRequestsModule {
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow,
- permissionsFlow = room.knockRequestPermissionsFlow(),
+ permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
+ perms.knockRequestPermissions()
+ },
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
coroutineScope = room.roomCoroutineScope
)
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
index 04c2f7b316..98570e6b28 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
@@ -8,6 +8,7 @@
package io.element.android.features.knockrequests.impl.data
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
index a1bb90cae8..ae770a297d 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
index 2c4c92a6b6..85fc0675ad 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
diff --git a/features/knockrequests/impl/src/main/res/values-hr/translations.xml b/features/knockrequests/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..7f8d39503f
--- /dev/null
+++ b/features/knockrequests/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,37 @@
+
+
+ "Da, prihvati sve"
+ "Jeste li sigurni da želite prihvatiti sve zahtjeve za pridruživanje?"
+ "Prihvati sve zahtjeve"
+ "Prihvati sve"
+ "Nismo mogli prihvatiti sve zahtjeve. Želite li pokušati ponovno?"
+ "Prihvaćanje svih zahtjeva nije uspjelo"
+ "Prihvaćanje svih zahtjeva za pridruživanje"
+ "Nismo mogli prihvatiti ovaj zahtjev. Želite li pokušati ponovno?"
+ "Prihvaćanje zahtjeva nije uspjelo"
+ "Prihvaća se zahtjev za pridruživanje"
+ "Da, odbij i zabrani"
+ "Jeste li sigurni da želite odbiti i zabraniti korisnika %1$s? Taj korisnik neće moći ponovno zatražiti pristup ovoj sobi."
+ "Odbij i zabrani pristup"
+ "Odbijanje i zabrana pristupa"
+ "Da, odbij"
+ "Jeste li sigurni da želite odbiti zahtjev korisnika %1$s za pridruživanje ovoj sobi?"
+ "Odbij pristup"
+ "Odbij i zabrani"
+ "Nismo mogli odbiti ovaj zahtjev. Želite li pokušati ponovno?"
+ "Odbijanje zahtjeva nije uspjelo"
+ "Odbijanje zahtjeva za pridruživanje"
+ "Kada netko zatraži pridruživanje sobi, ovdje ćete moći vidjeti njihov zahtjev."
+ "Nema zahtjeva za pridruživanje koji su na čekanju"
+ "Učitavanje zahtjeva za pridruživanje…"
+ "Zahtjevi za pridruživanje"
+
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+
+ "Prikaži sve"
+ "Prihvati"
+ "%1$s želi se pridružiti ovoj sobi"
+ "Prikaz"
+
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
index 9eaa51cecb..3161d3e81f 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.banner
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.test.A_USER_ID
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
index b0d0b68c91..7102b01773 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
@@ -11,7 +11,7 @@
package io.element.android.features.knockrequests.impl.list
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
diff --git a/features/leaveroom/api/src/main/res/values-hr/translations.xml b/features/leaveroom/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..4eb9e3405c
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Jeste li sigurni da želite napustiti ovaj razgovor? Ovaj razgovor nije javan i nećete se moći ponovno pridružiti bez pozivnice."
+ "Jeste li sigurni da želite napustiti ovu sobu? Ovdje ste jedino vi. Ako odete, nitko se ubuduće neće moći pridružiti, pa ni vi."
+ "Jeste li sigurni da želite napustiti ovu sobu? Ova soba nije javna i nećete joj se moći ponovno pridružiti bez pozivnice."
+ "Odaberi vlasnike"
+ "Vi ste jedini vlasnik ove sobe. Morate prenijeti vlasništvo na nekog drugog prije nego što napustite sobu."
+ "Prenesi vlasništvo"
+ "Jeste li sigurni da želite napustiti sobu?"
+
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
index c371d425c1..6455b45659 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
@@ -96,10 +96,7 @@ class LeaveRoomPresenter(
} else {
val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole
if (!hasPrivilegedCreatorRole) return false
-
- val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first()
- val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first()
- val owners = creators + superAdmins
+ val owners = usersWithRole { role -> role is RoomMember.Role.Owner }.first()
return owners.size == 1 && owners.first().userId == sessionId
}
}
diff --git a/features/linknewdevice/api/build.gradle.kts b/features/linknewdevice/api/build.gradle.kts
new file mode 100644
index 0000000000..7d368f0a63
--- /dev/null
+++ b/features/linknewdevice/api/build.gradle.kts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
new file mode 100644
index 0000000000..061bbeb084
--- /dev/null
+++ b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LinkNewDeviceEntryPoint : FeatureEntryPoint {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: Callback,
+ ): Node
+}
diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts
new file mode 100644
index 0000000000..9c1aa9e990
--- /dev/null
+++ b/features/linknewdevice/impl/build.gradle.kts
@@ -0,0 +1,63 @@
+import extension.setupDependencyInjection
+import extension.testCommonDependencies
+
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ id("kotlin-parcelize")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+setupDependencyInjection()
+
+dependencies {
+ // TODO Cleanup
+ implementation(projects.appconfig)
+ implementation(projects.features.enterprise.api)
+ implementation(projects.features.rageshake.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.libraries.qrcode)
+ implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.uiUtils)
+ implementation(projects.libraries.wellknown.api)
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.webkit)
+ implementation(libs.serialization.json)
+ api(projects.features.linknewdevice.api)
+
+ testCommonDependencies(libs, true)
+ testImplementation(projects.features.linknewdevice.test)
+ testImplementation(projects.features.enterprise.test)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.libraries.wellknown.test)
+}
diff --git a/features/linknewdevice/impl/src/main/AndroidManifest.xml b/features/linknewdevice/impl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..d225716fc4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
new file mode 100644
index 0000000000..5edc3cdfcd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesBinding(SessionScope::class)
+class DefaultLinkNewDeviceEntryPoint : LinkNewDeviceEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: LinkNewDeviceEntryPoint.Callback,
+ ): Node {
+ return parentNode.createNode(
+ buildContext = buildContext,
+ plugins = listOf(
+ callback,
+ )
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
new file mode 100644
index 0000000000..a8a8ff14b6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewDesktopHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private val linkDesktopStepFlow = MutableStateFlow(
+ LinkDesktopStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkDesktopStepFlow.asStateFlow()
+
+ private var currentJob: Job? = null
+ private var handler: LinkDesktopHandler? = null
+
+ fun createNewHandler() {
+ currentJob?.cancel()
+ currentJob = null
+ handler = matrixClient.createLinkDesktopHandler().getOrNull()
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
+ }
+ }
+
+ fun onScannedCode(data: ByteArray) {
+ currentJob?.cancel()
+ currentJob = null
+ val currentHandler = handler
+ if (currentHandler == null) {
+ Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
+ } else {
+ currentJob = matrixClient.sessionCoroutineScope.launch {
+ currentHandler.linkDesktopStep.onEach {
+ linkDesktopStepFlow.emit(it)
+ }.launchIn(this)
+ currentHandler.handleScannedQrCode(data)
+ }
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
new file mode 100644
index 0000000000..23c6b6ab2d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import android.app.Activity
+import android.os.Parcelable
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+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.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.pop
+import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.replace
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode
+import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
+import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
+import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
+import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
+import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
+import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+private val tag = LoggerTag("LinkNewDeviceFlowNode", LoggerTags.linkNewDevice)
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceFlowNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ private val callback: LinkNewDeviceEntryPoint.Callback = callback()
+ private var activity: Activity? = null
+ private var darkTheme: Boolean = false
+
+ override fun onBuilt() {
+ super.onBuilt()
+ var linkMobileHandlerJob: Job? = null
+ var linkDesktopHandlerJob: Job? = null
+
+ lifecycle.subscribe(
+ onCreate = {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ @Suppress("AssignedValueIsNeverRead")
+ linkMobileHandlerJob = observeLinkNewMobileHandler()
+ @Suppress("AssignedValueIsNeverRead")
+ linkDesktopHandlerJob = observeLinkNewDesktopHandler()
+ },
+ onDestroy = {
+ linkMobileHandlerJob?.cancel()
+ linkDesktopHandlerJob?.cancel()
+ }
+ )
+ }
+
+ sealed interface NavTarget : Parcelable {
+ // Will display the not supported state or the device type selection
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class MobileShowQrCode(
+ val data: String,
+ ) : NavTarget
+
+ @Parcelize
+ data object MobileEnterNumber : NavTarget
+
+ @Parcelize
+ data object DesktopNotice : NavTarget
+
+ @Parcelize
+ data object DesktopScanQrCode : NavTarget
+
+ @Parcelize
+ data class Error(
+ val errorScreenType: ErrorScreenType,
+ ) : NavTarget
+ }
+
+ private fun observeLinkNewMobileHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewMobileHandler")
+ return linkNewMobileHandler.stepFlow
+ .onEach { linkMobileStep ->
+ Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
+ when (linkMobileStep) {
+ LinkMobileStep.Uninitialized -> Unit
+ LinkMobileStep.Done -> {
+ callback.onDone()
+ }
+ is LinkMobileStep.Error -> {
+ navigateToError(linkMobileStep.errorType)
+ }
+ is LinkMobileStep.QrReady -> {
+ // The QrCode is ready, navigate to its display
+ backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
+ }
+ is LinkMobileStep.QrScanned -> {
+ backstack.replace(NavTarget.MobileEnterNumber)
+ }
+ LinkMobileStep.Starting -> {
+ // This step is not received at the moment, so do nothing
+ }
+ LinkMobileStep.SyncingSecrets -> {
+ // LinkMobileStep.Done is not received at the moment, so consider that the flow is done here
+ callback.onDone()
+ }
+ is LinkMobileStep.WaitingForAuth -> {
+ navigateToBrowser(linkMobileStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun observeLinkNewDesktopHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewDesktopHandler")
+ return linkNewDesktopHandler.stepFlow.onEach { linkDesktopStep ->
+ Timber.tag(tag.value).d("step: ${linkDesktopStep::class.java.simpleName}")
+ when (linkDesktopStep) {
+ LinkDesktopStep.Done -> callback.onDone()
+ is LinkDesktopStep.Error -> {
+ navigateToError(linkDesktopStep.errorType)
+ }
+ is LinkDesktopStep.EstablishingSecureChannel -> Unit
+ is LinkDesktopStep.InvalidQrCode -> {
+ // This error will be handled by the ScanQrCodeNode
+ }
+ LinkDesktopStep.Starting -> Unit
+ LinkDesktopStep.SyncingSecrets -> Unit
+ LinkDesktopStep.Uninitialized -> Unit
+ is LinkDesktopStep.WaitingForAuth -> {
+ navigateToBrowser(linkDesktopStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun navigateToError(errorType: ErrorType) {
+ // Map the error to an error screen
+ // TODO Update this mapping
+ val error = when (errorType) {
+ is ErrorType.DeviceIdAlreadyInUse -> ErrorScreenType.UnknownError
+ is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected
+ is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError
+ is ErrorType.NotFound -> ErrorScreenType.Expired
+ is ErrorType.UnableToCreateDevice -> ErrorScreenType.UnknownError
+ is ErrorType.Unknown -> ErrorScreenType.UnknownError
+ is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
+ }
+ // It is OK to push on backstack, since when user leaves the error screen, a new root will be set
+ backstack.push(NavTarget.Error(error))
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : LinkNewDeviceRootNode.Callback {
+ override fun onDone() {
+ callback.onDone()
+ }
+
+ override fun linkDesktopDevice() {
+ linkNewDesktopHandler.reset()
+ backstack.push(NavTarget.DesktopNotice)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopNotice -> {
+ val callback = object : DesktopNoticeNode.Callback {
+ override fun navigateBack() {
+ backstack.pop()
+ }
+
+ override fun navigateToQrCodeScanner() {
+ backstack.push(NavTarget.DesktopScanQrCode)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopScanQrCode -> {
+ val callback = object : ScanQrCodeNode.Callback {
+ override fun cancel() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.MobileEnterNumber -> {
+ val callback = object : EnterNumberNode.Callback {
+ override fun navigateToWrongNumberError() {
+ backstack.push(NavTarget.Error(ErrorScreenType.Mismatch2Digits))
+ }
+
+ override fun navigateBack() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ is NavTarget.MobileShowQrCode -> {
+ val callback = object : ShowQrCodeNode.Callback {
+ override fun navigateBack() {
+ linkNewMobileHandler.reset()
+ backstack.pop()
+ }
+ }
+ val inputs = ShowQrCodeNode.Inputs(
+ data = navTarget.data,
+ )
+ createNode(buildContext, listOf(inputs, callback))
+ }
+ is NavTarget.Error -> {
+ val callback = object : ErrorNode.Callback {
+ override fun onRetry() {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ backstack.newRoot(NavTarget.Root)
+ }
+ }
+ createNode(buildContext, listOf(callback, navTarget.errorScreenType))
+ }
+ }
+ }
+
+ private fun navigateToBrowser(url: String) {
+ activity?.openUrlInChromeCustomTab(null, darkTheme, url)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ activity = requireNotNull(LocalActivity.current)
+ darkTheme = !ElementTheme.isLightTheme
+ DisposableEffect(Unit) {
+ onDispose {
+ activity = null
+ }
+ }
+ BackstackView()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
new file mode 100644
index 0000000000..157d946eaa
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewMobileHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewMobileHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private var currentJob: Job? = null
+ private var handler: LinkMobileHandler? = null
+
+ private val linkMobileStepFlow = MutableStateFlow(
+ LinkMobileStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkMobileStepFlow.asStateFlow()
+
+ fun createAndStartNewHandler() {
+ Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
+ currentJob?.cancel()
+ handler = matrixClient.createLinkMobileHandler().getOrNull()
+ handler?.let { h ->
+ currentJob = sessionScope.launch {
+ h.linkMobileStep
+ .onEach {
+ linkMobileStepFlow.emit(it)
+ }
+ .launchIn(this)
+ h.start()
+ }
+ }
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
new file mode 100644
index 0000000000..3d94da3fc6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+sealed interface DesktopNoticeEvent {
+ data object Continue : DesktopNoticeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
new file mode 100644
index 0000000000..895d02731a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class DesktopNoticeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: DesktopNoticePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun navigateBack()
+ fun navigateToQrCodeScanner()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ DesktopNoticeView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ onReadyToScanClick = callback::navigateToQrCodeScanner,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
new file mode 100644
index 0000000000..3b01725fe1
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import android.Manifest
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.permissions.api.PermissionsEvent
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+
+@Inject
+class DesktopNoticePresenter(
+ permissionsPresenterFactory: PermissionsPresenter.Factory,
+) : Presenter {
+ private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
+ private var pendingPermissionRequest by mutableStateOf(false)
+
+ @Composable
+ override fun present(): DesktopNoticeState {
+ val cameraPermissionState = cameraPermissionPresenter.present()
+ var canContinue by remember { mutableStateOf(false) }
+ LaunchedEffect(cameraPermissionState.permissionGranted) {
+ if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
+ pendingPermissionRequest = false
+ canContinue = true
+ }
+ }
+
+ fun handleEvent(event: DesktopNoticeEvent) {
+ when (event) {
+ DesktopNoticeEvent.Continue -> if (cameraPermissionState.permissionGranted) {
+ canContinue = true
+ } else {
+ pendingPermissionRequest = true
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
+ }
+ }
+ }
+
+ return DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
new file mode 100644
index 0000000000..81991b5ab2
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import io.element.android.libraries.permissions.api.PermissionsState
+
+data class DesktopNoticeState(
+ val cameraPermissionState: PermissionsState,
+ val canContinue: Boolean,
+ val eventSink: (DesktopNoticeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
new file mode 100644
index 0000000000..194bd6fafc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2024, 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import android.Manifest
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.permissions.api.PermissionsState
+import io.element.android.libraries.permissions.api.aPermissionsState
+
+open class DesktopNoticeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aDesktopNoticeState(),
+ aDesktopNoticeState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)),
+ )
+}
+
+fun aDesktopNoticeState(
+ cameraPermissionState: PermissionsState = aPermissionsState(
+ showDialog = false,
+ permission = Manifest.permission.CAMERA,
+ ),
+ canContinue: Boolean = false,
+ eventSink: (DesktopNoticeEvent) -> Unit = {},
+) = DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
new file mode 100644
index 0000000000..c7f0bb61e7
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.permissions.api.PermissionsView
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * Desktop notice screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23618
+ */
+@Composable
+fun DesktopNoticeView(
+ state: DesktopNoticeState,
+ onBackClick: () -> Unit,
+ onReadyToScanClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val latestOnReadyToScanClick by rememberUpdatedState(onReadyToScanClick)
+ LaunchedEffect(state.canContinue) {
+ if (state.canContinue) {
+ latestOnReadyToScanClick()
+ }
+ }
+
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ buttons = {
+ Button(
+ text = stringResource(R.string.screen_link_new_device_desktop_submit),
+ onClick = { state.eventSink(DesktopNoticeEvent.Continue) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth()
+ ) {
+ Spacer(modifier = Modifier.height(40.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)),
+ )
+ )
+ }
+ }
+
+ PermissionsView(
+ title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title),
+ content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, appName),
+ icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) },
+ state = state.cameraPermissionState,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun DesktopNoticeViewPreview(
+ @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState,
+) = ElementPreview {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = { },
+ onReadyToScanClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
new file mode 100644
index 0000000000..70fd3b49a4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ErrorNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext = buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onRetry()
+ }
+
+ private val callback: Callback = callback()
+ private val errorScreenType = inputs()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ErrorView(
+ modifier = modifier,
+ errorScreenType = errorScreenType,
+ onRetry = callback::onRetry,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
new file mode 100644
index 0000000000..b92a19ef8a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.architecture.NodeInputs
+import kotlinx.parcelize.Parcelize
+
+@Immutable
+sealed interface ErrorScreenType : NodeInputs, Parcelable {
+ @Parcelize
+ data object Cancelled : ErrorScreenType
+
+ @Parcelize
+ data object Expired : ErrorScreenType
+
+ @Parcelize
+ data object Mismatch2Digits : ErrorScreenType
+
+ @Parcelize
+ data object InsecureChannelDetected : ErrorScreenType
+
+ @Parcelize
+ data object Declined : ErrorScreenType
+
+ @Parcelize
+ data object ProtocolNotSupported : ErrorScreenType
+
+ @Parcelize
+ data object SlidingSyncNotAvailable : ErrorScreenType
+
+ @Parcelize
+ data object UnknownError : ErrorScreenType
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
new file mode 100644
index 0000000000..7fd699101b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class ErrorScreenTypeProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ ErrorScreenType.Cancelled,
+ ErrorScreenType.Declined,
+ ErrorScreenType.Expired,
+ ErrorScreenType.ProtocolNotSupported,
+ ErrorScreenType.Mismatch2Digits,
+ ErrorScreenType.InsecureChannelDetected,
+ ErrorScreenType.SlidingSyncNotAvailable,
+ ErrorScreenType.UnknownError,
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
new file mode 100644
index 0000000000..3a77f19f49
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+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.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun ErrorView(
+ errorScreenType: ErrorScreenType,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ BackHandler(onBack = onRetry)
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.AlertSolid,
+ title = titleText(errorScreenType, appName),
+ subTitle = subtitleText(errorScreenType, appName),
+ content = { Content(errorScreenType) },
+ buttons = { Buttons(onRetry) },
+ )
+}
+
+@Composable
+private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
+ is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
+}
+
+@Composable
+private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
+ is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
+}
+
+@Composable
+private fun ColumnScope.InsecureChannelDetectedError() {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ textAlign = TextAlign.Center,
+ )
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)),
+ )
+ )
+}
+
+@Composable
+private fun Content(errorScreenType: ErrorScreenType) {
+ when (errorScreenType) {
+ ErrorScreenType.InsecureChannelDetected -> {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ InsecureChannelDetectedError()
+ }
+ }
+ else -> Unit
+ }
+}
+
+@Composable
+private fun Buttons(onRetry: () -> Unit) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_start_over),
+ onClick = onRetry
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) {
+ ElementPreview {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
new file mode 100644
index 0000000000..b61cc82a22
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+object Config {
+ const val VERIFICATION_CODE_LENGTH = 2
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
new file mode 100644
index 0000000000..267b508669
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+sealed interface EnterNumberEvent {
+ data class UpdateNumber(val number: String) : EnterNumberEvent
+ data object Continue : EnterNumberEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
new file mode 100644
index 0000000000..08d8cd02bd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+interface EnterNumberNavigator {
+ fun navigateToWrongNumberError()
+}
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class EnterNumberNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: EnterNumberPresenter.Factory,
+) : Node(buildContext, plugins = plugins), EnterNumberNavigator {
+ private val presenter = presenterFactory.create(this)
+
+ interface Callback : Plugin {
+ fun navigateToWrongNumberError()
+ fun navigateBack()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ EnterNumberView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+
+ override fun navigateToWrongNumberError() {
+ callback.navigateToWrongNumberError()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
new file mode 100644
index 0000000000..74b5b6b294
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val tag = LoggerTag("EnterNumberPresenter", LoggerTags.linkNewDevice)
+
+@AssistedInject
+class EnterNumberPresenter(
+ @Assisted private val navigator: EnterNumberNavigator,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: EnterNumberNavigator): EnterNumberPresenter
+ }
+
+ @Composable
+ override fun present(): EnterNumberState {
+ val coroutineScope = rememberCoroutineScope()
+ var number by remember { mutableStateOf("") }
+ var sendingCode by remember>> { mutableStateOf(AsyncAction.Uninitialized) }
+
+ // Observe the flow to react on ErrorType.InvalidCheckCode
+ val linkMobileStep by linkNewMobileHandler.stepFlow.collectAsState()
+
+ var checkCodeSender: CheckCodeSender? by remember { mutableStateOf(null) }
+
+ LaunchedEffect(linkMobileStep) {
+ when (val step = linkMobileStep) {
+ is LinkMobileStep.QrScanned -> {
+ checkCodeSender = step.checkCodeSender
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: EnterNumberEvent) {
+ when (event) {
+ is EnterNumberEvent.UpdateNumber -> {
+ sendingCode = AsyncAction.Uninitialized
+ // Keep only digits as a safety measure
+ number = event.number.filter { it.isDigit() }
+ }
+ EnterNumberEvent.Continue -> coroutineScope.launch {
+ // Get the current code sender
+ val sender = checkCodeSender
+ if (sender == null) {
+ Timber.tag(tag.value).e("No check code sender available")
+ sendingCode = AsyncAction.Failure(IllegalStateException("No check code sender available"))
+ } else {
+ sendingCode = AsyncAction.Loading
+ val uByte = number.toUByte()
+ val isValid = sender.validate(uByte)
+ if (isValid) {
+ sender.send(uByte)
+ .fold(
+ onSuccess = {
+ Timber.tag(tag.value).d("Code sent successfully")
+ // Keep loading, do not set sendingCode to AsyncAction.Success(Unit)
+ },
+ onFailure = {
+ Timber.tag(tag.value).e(it, "Failed to send number code")
+ sendingCode = AsyncAction.Failure(it)
+ }
+ )
+ } else {
+ // Navigate to the error state
+ navigator.navigateToWrongNumberError()
+ }
+ }
+ }
+ }
+ }
+
+ return EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
new file mode 100644
index 0000000000..b8f66018dd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.architecture.AsyncAction
+
+data class EnterNumberState(
+ val number: String,
+ val sendingCode: AsyncAction,
+ val eventSink: (EnterNumberEvent) -> Unit,
+) {
+ val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number)
+ val isContinueButtonEnabled: Boolean
+ get() = numberEntry.isComplete() && !sendingCode.isLoading()
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
new file mode 100644
index 0000000000..126bfeee52
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class EnterNumberStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aEnterNumberState(),
+ aEnterNumberState(number = "1"),
+ aEnterNumberState(number = "12"),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Loading),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(ErrorType.InvalidCheckCode("Invalid"))),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(Exception("Failed to send code"))),
+ )
+}
+
+fun aEnterNumberState(
+ number: String = "",
+ sendingCode: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (EnterNumberEvent) -> Unit = {},
+) = EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
new file mode 100644
index 0000000000..92b3447615
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.linknewdevice.impl.R
+import io.element.android.features.linknewdevice.impl.screens.number.component.NumberTextField
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+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.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Form to enter number:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2076-81604
+ */
+@Composable
+fun EnterNumberView(
+ state: EnterNumberState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_enter_number_title),
+ subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ buttons = {
+ Button(
+ text = stringResource(CommonStrings.action_continue),
+ onClick = { state.eventSink(EnterNumberEvent.Continue) },
+ enabled = state.isContinueButtonEnabled,
+ showProgress = state.sendingCode.isLoading(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.screen_link_new_device_enter_number_notice),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ NumberTextField(
+ number = state.numberEntry,
+ onValueChange = { state.eventSink(EnterNumberEvent.UpdateNumber(it)) },
+ onDone = {
+ if (state.isContinueButtonEnabled) {
+ state.eventSink(EnterNumberEvent.Continue)
+ }
+ },
+ )
+ val failure = state.sendingCode.errorOrNull()
+ if (failure != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(14.dp),
+ imageVector = CompoundIcons.ErrorSolid(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ val errorMessage = when (failure) {
+ is ErrorType.InvalidCheckCode -> stringResource(R.string.screen_link_new_device_enter_number_error_numbers_do_not_match)
+ else -> failure.message ?: stringResource(CommonStrings.error_unknown)
+ }
+ Text(
+ text = errorMessage,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textCriticalPrimary,
+ )
+ }
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun EnterNumberViewPreview(
+ @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState,
+) = ElementPreview {
+ EnterNumberView(
+ state = state,
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
new file mode 100644
index 0000000000..568a729ed6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.platform.LocalInspectionMode
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import kotlinx.coroutines.delay
+
+@Composable
+fun NumberTextField(
+ number: Number,
+ onValueChange: (String) -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused = LocalInspectionMode.current || interactionSource.collectIsFocusedAsState().value
+ BasicTextField(
+ modifier = modifier,
+ value = number.toText(),
+ onValueChange = {
+ onValueChange(it)
+ },
+ interactionSource = interactionSource,
+ maxLines = 1,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ onDone()
+ }
+ ),
+ decorationBox = {
+ NumberRow(
+ number = number,
+ hasFocus = isFocused,
+ )
+ }
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun NumberRow(
+ number: Number,
+ hasFocus: Boolean,
+) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val length = number.length()
+ number.digits.forEachIndexed { index, digit ->
+ DigitView(
+ digit = digit,
+ isCurrent = index == length,
+ drawCursor = hasFocus,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DigitView(
+ digit: Digit,
+ isCurrent: Boolean,
+ drawCursor: Boolean,
+) {
+ val shape = RoundedCornerShape(4.dp)
+ val appearanceModifier = when (digit) {
+ Digit.Empty -> {
+ val color = if (isCurrent) {
+ ElementTheme.colors.textPrimary
+ } else {
+ ElementTheme.colors.borderInteractiveSecondary
+ }
+ Modifier.border(1.dp, color, shape)
+ }
+ is Digit.Filled -> {
+ Modifier.background(ElementTheme.colors.bgActionSecondaryPressed, shape)
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(42.dp, 56.dp)
+ .then(appearanceModifier),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (digit is Digit.Filled) {
+ Text(
+ text = digit.value.toString(),
+ style = ElementTheme.typography.fontHeadingLgBold,
+ color = ElementTheme.colors.textPrimary,
+ )
+ } else if (drawCursor && isCurrent) {
+ // Draw a blinking cursor
+ BlinkingCursor()
+ }
+ }
+}
+
+@Composable
+private fun BlinkingCursor() {
+ var isCursorVisible by remember { mutableStateOf(true) }
+ LaunchedEffect(isCursorVisible) {
+ delay(500)
+ // Toggle cursor visibility
+ isCursorVisible = !isCursorVisible
+ }
+ if (isCursorVisible) {
+ Spacer(
+ modifier = Modifier
+ .size(2.dp, 24.dp)
+ .offset(x = (-5).dp)
+ .background(ElementTheme.colors.textPrimary, RoundedCornerShape(1.dp))
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun NumberTextFieldPreview() {
+ ElementPreview {
+ val number = Number.createEmpty(4).fillWith("12")
+ NumberTextField(
+ number = number,
+ onValueChange = {},
+ onDone = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
new file mode 100644
index 0000000000..b8565ea6ca
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface Digit {
+ data object Empty : Digit
+ data class Filled(val value: Char) : Digit
+
+ fun toText(): String {
+ return when (this) {
+ is Empty -> ""
+ is Filled -> value.toString()
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
new file mode 100644
index 0000000000..be60f27f76
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+data class Number(
+ val digits: ImmutableList,
+) {
+ companion object {
+ fun createEmpty(size: Int): Number {
+ val digits = List(size) { Digit.Empty }
+ return Number(
+ digits = digits.toImmutableList()
+ )
+ }
+ }
+
+ val size = digits.size
+
+ /**
+ * Fill the first digits with the given text.
+ * Can't be more than the size of the NumberEntry
+ * Keep the Empty digits at the end
+ * @return the new NumberEntry
+ */
+ fun fillWith(text: String): Number {
+ val newDigits = MutableList(size) { Digit.Empty }
+ text.forEachIndexed { index, char ->
+ if (index < size && char.isDigit()) {
+ newDigits[index] = Digit.Filled(char)
+ }
+ }
+ return copy(digits = newDigits.toImmutableList())
+ }
+
+ fun length(): Int {
+ return digits.count { it is Digit.Filled }
+ }
+
+ fun toText(): String {
+ return digits.joinToString("") {
+ it.toText()
+ }
+ }
+
+ fun isComplete(): Boolean {
+ return digits.all { it is Digit.Filled }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
new file mode 100644
index 0000000000..a884c3e97f
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ShowQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+ class Inputs(
+ val data: String,
+ ) : NodeInputs
+
+ interface Callback : Plugin {
+ fun navigateBack()
+ }
+
+ private val inputs: Inputs = inputs()
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ShowQrCodeView(
+ data = inputs.data,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
new file mode 100644
index 0000000000..501415f621
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.qrcode.QrCodeImage
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * QrCode display screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
+ */
+@Composable
+fun ShowQrCodeView(
+ data: String,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_mobile_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.TakePhotoSolid()),
+ modifier = modifier,
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ QrCodeImage(
+ data = data,
+ modifier = Modifier
+ .size(220.dp)
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step3)),
+ )
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ShowQrCodeViewPreview() = ElementPreview {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
new file mode 100644
index 0000000000..8ce6af90b0
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+sealed interface LinkNewDeviceRootEvent {
+ data object LinkMobileDevice : LinkNewDeviceRootEvent
+ data object CloseDialog : LinkNewDeviceRootEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
new file mode 100644
index 0000000000..f43ffa64df
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceRootNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LinkNewDeviceRootPresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onDone()
+ fun linkDesktopDevice()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LinkNewDeviceRootView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::onDone,
+ onLinkDesktopDeviceClick = callback::linkDesktopDevice,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
new file mode 100644
index 0000000000..a17ed88fd3
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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 dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import kotlinx.coroutines.launch
+
+@Inject
+class LinkNewDeviceRootPresenter(
+ private val matrixClient: MatrixClient,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @Composable
+ override fun present(): LinkNewDeviceRootState {
+ val coroutineScope = rememberCoroutineScope()
+ var isSupported by remember { mutableStateOf>(AsyncData.Uninitialized) }
+ var qrCodeData by remember { mutableStateOf>(AsyncData.Uninitialized) }
+
+ LaunchedEffect(Unit) {
+ matrixClient.canLinkNewDevice().fold(
+ onSuccess = { supported ->
+ isSupported = AsyncData.Success(supported)
+ },
+ onFailure = {
+ isSupported = AsyncData.Failure(it)
+ }
+ )
+ }
+
+ val step by linkNewMobileHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(step) {
+ when (val finalStep = step) {
+ is LinkMobileStep.Uninitialized -> {
+ qrCodeData = AsyncData.Uninitialized
+ }
+ is LinkMobileStep.QrReady -> {
+ qrCodeData = AsyncData.Success(Unit)
+ }
+ is LinkMobileStep.Error -> {
+ qrCodeData = AsyncData.Failure(finalStep.errorType)
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: LinkNewDeviceRootEvent) {
+ when (event) {
+ LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
+ qrCodeData = AsyncData.Loading()
+ // Wait for the QrCode to be ready
+ linkNewMobileHandler.reset()
+ linkNewMobileHandler.createAndStartNewHandler()
+ }
+ LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
+ linkNewMobileHandler.reset()
+ }
+ }
+ }
+
+ return LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
new file mode 100644
index 0000000000..6cf6694b2a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import io.element.android.libraries.architecture.AsyncData
+
+data class LinkNewDeviceRootState(
+ val isSupported: AsyncData,
+ val qrCodeData: AsyncData,
+ val eventSink: (LinkNewDeviceRootEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
new file mode 100644
index 0000000000..f1bb7ad455
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class LinkNewDeviceRootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLinkNewDeviceRootState(),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(true)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(false)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Failure(Exception("Should not happen"))),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Loading(),
+ ),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Failure(ErrorType.NotFound("The rendezvous session was not found and might have expired")),
+ ),
+ )
+}
+
+fun aLinkNewDeviceRootState(
+ isSupported: AsyncData = AsyncData.Uninitialized,
+ qrCodeData: AsyncData = AsyncData.Uninitialized,
+ eventSink: (LinkNewDeviceRootEvent) -> Unit = { },
+) = LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
new file mode 100644
index 0000000000..d9249d3e89
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.foundation.layout.fillMaxWidth
+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.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
+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.ErrorDialog
+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.IconSource
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Device selection screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23616
+ * Not supported screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2186-70004
+ */
+@Composable
+fun LinkNewDeviceRootView(
+ state: LinkNewDeviceRootState,
+ onBackClick: () -> Unit,
+ onLinkDesktopDeviceClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
+ Triple(
+ stringResource(R.string.screen_link_new_device_error_not_supported_title),
+ stringResource(R.string.screen_link_new_device_error_not_supported_subtitle),
+ BigIcon.Style.AlertSolid
+ )
+ } else {
+ Triple(
+ stringResource(R.string.screen_link_new_device_root_title),
+ null,
+ BigIcon.Style.Default(CompoundIcons.Devices())
+ )
+ }
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = title,
+ subTitle = subtitle,
+ iconStyle = iconStyle,
+ buttons = {
+ when (state.isSupported) {
+ is AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ LoadingButtonAtom()
+ }
+ is AsyncData.Failure -> {
+ Text(
+ text = stringResource(id = CommonStrings.error_unknown),
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ textAlign = TextAlign.Center,
+ )
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ is AsyncData.Success -> {
+ if (state.isSupported.data) {
+ when (state.qrCodeData) {
+ AsyncData.Uninitialized,
+ is AsyncData.Failure -> {
+ Button(
+ onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
+ text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
+ )
+ Button(
+ onClick = onLinkDesktopDeviceClick,
+ text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
+ )
+ }
+ is AsyncData.Loading,
+ is AsyncData.Success -> {
+ Button(
+ onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
+ text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
+ showProgress = true,
+ enabled = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = onLinkDesktopDeviceClick,
+ text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
+ modifier = Modifier.fillMaxWidth(),
+ enabled = false,
+ leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
+ )
+ }
+ }
+ } else {
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ },
+ modifier = modifier,
+ )
+
+ val failure = state.qrCodeData.errorOrNull()
+ if (failure != null) {
+ ErrorDialog(
+ content = failure.message ?: stringResource(CommonStrings.error_unknown),
+ onSubmit = { state.eventSink(LinkNewDeviceRootEvent.CloseDialog) },
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LinkNewDeviceRootViewPreview(
+ @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState
+) = ElementPreview {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = { },
+ onLinkDesktopDeviceClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
new file mode 100644
index 0000000000..c17a28649c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+sealed interface ScanQrCodeEvent {
+ data class QrCodeScanned(val data: ByteArray) : ScanQrCodeEvent
+ data object TryAgain : ScanQrCodeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
new file mode 100644
index 0000000000..ff4f88f9ae
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+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 dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ScanQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: ScanQrCodePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun cancel()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ScanQrCodeView(
+ state = state,
+ onBackClick = callback::cancel,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
new file mode 100644
index 0000000000..5f0131a8df
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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 dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import kotlinx.coroutines.launch
+
+@Inject
+class ScanQrCodePresenter(
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : Presenter {
+ @Composable
+ override fun present(): ScanQrCodeState {
+ val coroutineScope = rememberCoroutineScope()
+ var scanAction: AsyncAction by remember { mutableStateOf(AsyncAction.Loading) }
+
+ // Observe the flow to react on LinkDesktopStep.InvalidQrCode
+ val linkDesktopStep by linkNewDesktopHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(Unit) {
+ linkNewDesktopHandler.createNewHandler()
+ }
+
+ LaunchedEffect(linkDesktopStep) {
+ when (val step = linkDesktopStep) {
+ is LinkDesktopStep.InvalidQrCode -> {
+ scanAction = AsyncAction.Failure(Exception(step.error))
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: ScanQrCodeEvent) {
+ when (event) {
+ ScanQrCodeEvent.TryAgain -> {
+ scanAction = AsyncAction.Loading
+ }
+ is ScanQrCodeEvent.QrCodeScanned -> coroutineScope.launch {
+ // In this case the scanning will stop and a loader will be shown
+ scanAction = AsyncAction.Success(Unit)
+ try {
+ linkNewDesktopHandler.onScannedCode(event.data)
+ } catch (e: Exception) {
+ // Should not happen as errors are handled through the LinkDesktopStep flow
+ scanAction = AsyncAction.Failure(e)
+ }
+ }
+ }
+ }
+
+ return ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
new file mode 100644
index 0000000000..6cb0363836
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class ScanQrCodeState(
+ val scanAction: AsyncAction,
+ val eventSink: (ScanQrCodeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
new file mode 100644
index 0000000000..40e7df8884
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+
+open class ScanQrCodeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aScanQrCodeState(),
+ aScanQrCodeState(scanAction = AsyncAction.Loading),
+ aScanQrCodeState(scanAction = AsyncAction.Success(Unit)),
+ aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))),
+ )
+}
+
+fun aScanQrCodeState(
+ scanAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (ScanQrCodeEvent) -> Unit = {},
+) = ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
new file mode 100644
index 0000000000..937aee08b4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+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
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.modifiers.cornerBorder
+import io.element.android.libraries.designsystem.modifiers.squareSize
+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.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.qrcode.QrCodeCameraView
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun ScanQrCodeView(
+ state: ScanQrCodeState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ modifier = modifier,
+ onBackClick = onBackClick,
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ title = stringResource(R.string.screen_link_new_device_desktop_scanning_title),
+ content = { Content(state = state) },
+ buttons = { Buttons(state = state) }
+ )
+}
+
+@Composable
+private fun Content(
+ state: ScanQrCodeState,
+) {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val modifier = if (constraints.maxWidth > constraints.maxHeight) {
+ Modifier.fillMaxHeight()
+ } else {
+ Modifier.fillMaxWidth()
+ }.then(
+ Modifier
+ .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp)
+ .squareSize()
+ .cornerBorder(
+ strokeWidth = 4.dp,
+ color = ElementTheme.colors.textPrimary,
+ cornerSizeDp = 42.dp,
+ )
+ )
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ QrCodeCameraView(
+ modifier = Modifier.fillMaxSize(),
+ onScanQrCode = { state.eventSink.invoke(ScanQrCodeEvent.QrCodeScanned(it)) },
+ isScanning = state.scanAction.isLoading(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Buttons(
+ state: ScanQrCodeState,
+) {
+ Column(Modifier.heightIn(min = 130.dp)) {
+ when (state.scanAction) {
+ is AsyncAction.Failure -> {
+ Button(
+ text = stringResource(id = CommonStrings.action_try_again),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ onClick = {
+ state.eventSink.invoke(ScanQrCodeEvent.TryAgain)
+ }
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.ErrorSolid(),
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle),
+ textAlign = TextAlign.Center,
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ )
+ }
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_description),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ is AsyncAction.Success -> {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .progressSemantics()
+ .size(20.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ AsyncAction.Loading,
+ AsyncAction.Uninitialized,
+ is AsyncAction.Confirming -> Unit
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = {},
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/res/values-be/translations.xml b/features/linknewdevice/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 0000000000..16372fa6e4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Сканіраваць QR-код"
+ "Адсканіруйце QR-код з дапамогай гэтай прылады"
+ "Гатовы да сканіравання"
+ "Ваш правайдар уліковага запісу не падтрымлівае %1$s."
+ "%1$s не падтрымліваецца"
+ "QR-код не падтрымліваецца"
+ "Уваход быў адменены на іншай прыладзе."
+ "Запыт на ўваход скасаваны"
+ "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз."
+ "Уваход у сістэму не быў завершаны своечасова"
+ "Выберыце %1$s"
+ "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."
+ "Што зараз?"
+ "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"
+ "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."
+ "Калі гэта не дапамагло, увайдзіце ўручную"
+ "Злучэнне небяспечнае"
+ "Уваход быў адменены на іншай прыладзе."
+ "Запыт на ўваход скасаваны"
+ "Уваход на іншай прыладзе быў адхілены."
+ "Уваход адхілены"
+ "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз."
+ "Уваход у сістэму не быў завершаны своечасова"
+ "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода.
+
+Паспрабуйце ўвайсці ў сістэму ўручную або адсканіруйце QR-код з дапамогай іншай прылады."
+ "QR-код не падтрымліваецца"
+ "Ваш правайдар уліковага запісу не падтрымлівае %1$s."
+ "%1$s не падтрымліваецца"
+ "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."
+ "Паўтарыць спробу"
+ "Няправільны QR-код"
+ "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."
+ "Дазвольце доступ да камеры для сканіравання QR-кода"
+ "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-bg/translations.xml b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml
new file mode 100644
index 0000000000..e8a8ea95d3
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Повторен опит"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-cs/translations.xml b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..4b8f230d55
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Naskenujte QR kód"
+ "Otevřete %1$s na notebooku nebo stolním počítači"
+ "Naskenujte QR kód pomocí tohoto zařízení"
+ "Připraveno ke skenování"
+ "Otevřete %1$s na stolním počítači a získejte QR kód"
+ "Čísla se neshodují"
+ "Zadejte dvoumístný kód"
+ "Tím ověříte, zda je připojení k druhému zařízení bezpečné."
+ "Zadejte číslo zobrazené na druhém zařízení"
+ "Váš poskytovatel účtu nepodporuje %1$s."
+ "%1$s není podporováno"
+ "Poskytovatel vašeho účtu nepodporuje přihlašování do nového zařízení pomocí QR kódu."
+ "QR kód není podporován"
+ "Přihlášení bylo na druhém zařízení zrušeno."
+ "Žádost o přihlášení zrušena"
+ "Platnost přihlášení vypršela. Zkuste to prosím znovu."
+ "Přihlášení nebylo dokončeno včas"
+ "Otevřete %1$s na druhém zařízení"
+ "Vybrat %1$s"
+ "„Přihlásit se pomocí QR kódu“"
+ "Naskenujte zde zobrazený QR kód pomocí jiného zařízení"
+ "Otevřete %1$s na druhém zařízení"
+ "Stolní počítač"
+ "Načítání QR kódu…"
+ "Mobilní zařízení"
+ "Jaký typ zařízení chcete propojit?"
+ "Zkuste to prosím znovu a ujistěte se, že jste zadali dvoumístný kód správně. Pokud se čísla stále neshodují, kontaktujte poskytovatele účtu."
+ "Čísla se neshodují"
+ "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."
+ "Co teď?"
+ "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"
+ "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi"
+ "Pokud to nefunguje, přihlaste se ručně"
+ "Připojení není zabezpečené"
+ "Přihlášení bylo na druhém zařízení zrušeno."
+ "Žádost o přihlášení zrušena"
+ "Přihlášení bylo na druhém zařízení odmítnuto."
+ "Přihlášení odmítnuto"
+ "Nemusíte dělat nic jiného."
+ "Vaše další zařízení je již přihlášeno"
+ "Platnost přihlášení vypršela. Zkuste to prosím znovu."
+ "Přihlášení nebylo dokončeno včas"
+ "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu.
+
+Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení."
+ "QR kód není podporován"
+ "Váš poskytovatel účtu nepodporuje %1$s."
+ "%1$s není podporováno"
+ "Použijte QR kód zobrazený na druhém zařízení."
+ "Zkusit znovu"
+ "Špatný QR kód"
+ "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."
+ "Povolte přístup k fotoaparátu a naskenujte QR kód"
+ "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-cy/translations.xml b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 0000000000..b26aed52ef
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Sganiwch y cod QR"
+ "Sganiwch y cod QR gyda\'r ddyfais hon"
+ "Yn barod i sganio"
+ "Nid yw darparwr eich cyfrif yn cefnogi %1$s."
+ "%1$s heb ei gefnogi"
+ "Nid yw\'r cod QR yn cael ei gefnogi"
+ "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall."
+ "Cais mewngofnodi wedi\'i ddiddymu"
+ "Mewngofnodi wedi dod i ben. Ceisiwch eto."
+ "Heb gwblhau\'r mewngofnodi mewn pryd"
+ "Dewiswch %1$s"
+ "Nid oedd modd gwneud cysylltiad diogel â\'r ddyfais newydd. Mae eich dyfeisiau presennol yn dal yn ddiogel a does dim angen i chi boeni amdanyn nhw."
+ "Beth nawr?"
+ "Ceisiwch fewngofnodi eto gyda chod QR rhag ofn bod hyn yn broblem rhwydwaith"
+ "Os ydych chi\'n dod ar draws yr un broblem, rhowch gynnig ar rwydwaith wifi gwahanol neu defnyddiwch eich data symudol yn lle wifi"
+ "Os nad yw hynny\'n gweithio, mewngofnodwch â llaw"
+ "Nid yw\'r cysylltiad yn ddiogel"
+ "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall."
+ "Cais mewngofnodi wedi\'i ddiddymu"
+ "Cafodd y mewngofnodi ar y ddyfais arall ei wrthod."
+ "Gwrthodwyd y mewngofnodi"
+ "Mewngofnodi wedi dod i ben. Ceisiwch eto."
+ "Heb gwblhau\'r mewngofnodi mewn pryd"
+ "Nid yw eich dyfais arall yn cefnogi mewngofnodi i %s gyda chod QR.
+
+Ceisiwch fewngofnodi â llaw, neu sganiwch y cod QR gyda dyfais arall."
+ "Nid yw\'r cod QR yn cael ei gefnogi"
+ "Nid yw darparwr eich cyfrif yn cefnogi %1$s."
+ "%1$s heb ei gefnogi"
+ "Defnyddiwch y cod QR sy\'n cael ei ddangos ar y ddyfais arall."
+ "Ceisiwch eto"
+ "Cod QR anghywir"
+ "Mae angen i chi roi caniatâd i %1$s ddefnyddio camera eich dyfais er mwyn parhau."
+ "Caniatáu mynediad camera i sganio\'r cod QR"
+ "Digwyddodd gwall annisgwyl. Ceisiwch eto."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-da/translations.xml b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 0000000000..a31175c1d5
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scan QR-koden"
+ "Scan QR-koden med denne enhed"
+ "Klar til at scanne"
+ "Din kontoudbyder understøtter ikke %1$s."
+ "%1$s understøttes ikke"
+ "QR-kode understøttes ikke"
+ "Login blev annulleret på den anden enhed."
+ "Anmodning om login annulleret"
+ "Login er udløbet. Prøv venligst igen."
+ "Login blev ikke afsluttet i tide"
+ "Vælg %1$s"
+ "Der kunne ikke oprettes en sikker forbindelse til den nye enhed. Dine eksisterende enheder er stadig sikre, og du behøver ikke bekymre dig om dem."
+ "Hvad nu?"
+ "Prøv at logge ind igen med en QR-kode, hvis dette skyldtes et netværksproblem"
+ "Hvis du støder på det samme problem, kan du prøve et andet wifi-netværk eller bruge dine mobildata i stedet for wifi"
+ "Hvis det ikke virker, skal du logge ind manuelt"
+ "Forbindelsen er ikke sikker"
+ "Login blev annulleret på den anden enhed."
+ "Anmodning om login annulleret"
+ "Login blev afvist på den anden enhed."
+ "Login afvist"
+ "Login er udløbet. Prøv venligst igen."
+ "Login blev ikke afsluttet i tide"
+ "Din anden enhed understøtter ikke at logge ind på %s med en QR-kode.
+
+Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed."
+ "QR-kode understøttes ikke"
+ "Din kontoudbyder understøtter ikke %1$s."
+ "%1$s understøttes ikke"
+ "Brug den QR-kode, der bliver vist på den anden enhed."
+ "Prøv igen"
+ "Forkert QR-kode"
+ "Du skal give tilladelse til at %1$s kan benytte enhedens kamera, for at fortsætte."
+ "Tillad kameraadgang for at scanne QR-koden"
+ "Der opstod en uventet fejl. Prøv venligst igen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-de/translations.xml b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..b8ad8b80ef
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "QR-Code scannen"
+ "Öffne %1$s auf einem Laptop oder Desktop-Computer"
+ "Scanne den QR-Code mit diesem Gerät"
+ "Bereit zum Scannen"
+ "Öffne %1$s auf einem Desktop-Computer, um den QR-Code zu erhalten"
+ "Die Zahlen stimmen nicht überein"
+ "Gib den 2-stelligen Code ein"
+ "Dadurch wird überprüft, ob die Verbindung zu deinem anderen Gerät sicher ist."
+ "Gib die Nummer ein, die auf deinem anderen Gerät angezeigt wird"
+ "Dein Kontoanbieter unterstützt %1$s nicht."
+ "%1$s wird nicht unterstützt"
+ "Dein Kontoanbieter unterstützt die Anmeldung auf einem neuen Gerät mit einem QR-Code nicht."
+ "QR-Code wird nicht unterstützt"
+ "Die Anmeldung wurde auf dem anderen Gerät abgebrochen."
+ "Anmeldeanfrage abgebrochen"
+ "Die Anmeldung ist abgelaufen. Bitte versuche es erneut."
+ "Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
+ "Öffne %1$s auf dem anderen Gerät"
+ "Wähle %1$s"
+ "„Mit QR-Code anmelden”"
+ "Scanne den hier gezeigten QR-Code mit dem anderen Gerät."
+ "Öffne %1$s auf dem anderen Gerät"
+ "Desktop-Computer"
+ "QR-Code wird geladen…"
+ "Mobilgerät"
+ "Welchen Gerätetyp möchtest du verknüpfen?"
+ "Versuch\' es bitte noch mal und stell sicher, dass du den zweistelligen Code richtig eingegeben hast. Wenn die Zahlen immer noch nicht übereinstimmen, wende dich an deinen Kontoanbieter."
+ "Die Zahlen stimmen nicht überein"
+ "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."
+ "Und jetzt?"
+ "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."
+ "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."
+ "Wenn das nicht funktioniert, melde dich manuell an"
+ "Die Verbindung ist nicht sicher"
+ "Die Anmeldung wurde auf dem anderen Gerät abgebrochen."
+ "Anmeldeanfrage abgebrochen"
+ "Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
+ "Anmelden abgelehnt"
+ "Du musst nichts weiter tun."
+ "Dein anderes Gerät ist schon angemeldet."
+ "Die Anmeldung ist abgelaufen. Bitte versuche 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"
+ "Dein Kontoanbieter unterstützt %1$s nicht."
+ "%1$s wird nicht unterstützt"
+ "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."
+ "Erneut versuchen"
+ "Falscher QR-Code"
+ "Du musst %1$s die Berechtigung erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."
+ "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"
+ "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-el/translations.xml b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
new file mode 100644
index 0000000000..2133bb352c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Σάρωση κωδικού QR"
+ "Σάρωσε τον κωδικό QR με αυτήν τη συσκευή"
+ "Έτοιμο για σάρωση"
+ "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s."
+ "Το %1$s δεν υποστηρίζεται"
+ "Ο κωδικός QR δεν υποστηρίζεται"
+ "Η σύνδεση ακυρώθηκε στην άλλη συσκευή."
+ "Το αίτημα σύνδεσης ακυρώθηκε"
+ "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά."
+ "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως"
+ "Επιλογή %1$s"
+ "Δεν ήταν δυνατή η πραγματοποίηση ασφαλούς σύνδεσης στη νέα συσκευή. Οι υπάρχουσες συσκευές σας εξακολουθούν να είναι ασφαλείς και δεν χρειάζεται να ανησυχείς για αυτές."
+ "Τί είναι πάλι;"
+ "Δοκίμασε να συνδεθείς ξανά με έναν κωδικό QR σε περίπτωση που ήταν πρόβλημα του δικτύου"
+ "Εάν αντιμετωπίσεις το ίδιο πρόβλημα, δοκίμασε ένα διαφορετικό δίκτυο wifi ή χρησιμοποίησε τα δεδομένα του κινητού σου αντί για wifi"
+ "Εάν δεν λειτουργήσει, συνδέσου χειροκίνητα"
+ "Η σύνδεση δεν είναι ασφαλής"
+ "Η σύνδεση ακυρώθηκε στην άλλη συσκευή."
+ "Το αίτημα σύνδεσης ακυρώθηκε"
+ "Η σύνδεση απορρίφθηκε στην άλλη συσκευή."
+ "Η σύνδεση απορρίφθηκε"
+ "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά."
+ "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως"
+ "Η άλλη σου συσκευή δεν υποστηρίζει σύνδεση στο %s με κωδικό QR.
+
+Δοκίμασε να συνδεθείς χειροκίνητα ή σάρωσε τον κωδικό QR με άλλη συσκευή."
+ "Ο κωδικός QR δεν υποστηρίζεται"
+ "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s."
+ "Το %1$s δεν υποστηρίζεται"
+ "Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή."
+ "Προσπάθησε ξανά"
+ "Λάθος κωδικός QR"
+ "Πρέπει να δώσεις άδεια για %1$s για να χρησιμοποιήσεις την κάμερα της συσκευής σου και να συνεχίσεις."
+ "Επέτρεψε την πρόσβαση της κάμερας για σάρωση του κωδικού QR"
+ "Παρουσιάστηκε ένα απροσδόκητο σφάλμα. Παρακαλώ προσπάθησε ξανά."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml
new file mode 100644
index 0000000000..6395ce9e54
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "If you encounter the same problem, try a different Wi-Fi network or use your mobile data instead of Wi-Fi"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 0000000000..032813a2c4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Escanea el código QR"
+ "Escanea el código QR con este dispositivo"
+ "Listo para escanear"
+ "Tu proveedor de cuentas no es compatible con %1$s."
+ "%1$s no admitido"
+ "Código QR no admitido"
+ "El inicio de sesión se canceló en el otro dispositivo."
+ "Solicitud de inicio de sesión cancelada"
+ "El inicio de sesión ha caducado. Inténtalo de nuevo."
+ "El inicio de sesión no se completó a tiempo"
+ "Selecciona %1$s"
+ "No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos."
+ "¿Y ahora qué?"
+ "Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red"
+ "Si te encuentras con el mismo problema, prueba con una red wifi diferente o usa tus datos móviles en lugar de wifi"
+ "Si eso no funciona, inicia sesión manualmente"
+ "La conexión no es segura"
+ "El inicio de sesión se canceló en el otro dispositivo."
+ "Solicitud de inicio de sesión cancelada"
+ "El inicio de sesión se rechazó en el otro dispositivo."
+ "Inicio de sesión rechazado"
+ "El inicio de sesión ha caducado. Inténtalo de nuevo."
+ "El inicio de sesión no se completó a tiempo"
+ "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR.
+
+Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo."
+ "Código QR no admitido"
+ "Tu proveedor de cuentas no es compatible con %1$s."
+ "%1$s no admitido"
+ "Usa el código QR que se muestra en el otro dispositivo."
+ "Intentar de nuevo"
+ "Código QR incorrecto"
+ "Tienes que dar permiso a %1$s para que utilice la cámara de tu dispositivo y así poder continuar."
+ "Permite el acceso a la cámara para escanear el código QR"
+ "Se ha producido un error inesperado. Vuelve a intentarlo."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
new file mode 100644
index 0000000000..6aa1398e0a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Skaneeri QR-koodi"
+ "Ava %1$s kas oma süle- või lauaarvutis"
+ "Skaneeri QR-koodi selle seadmega"
+ "Skaneerimiseks valmis"
+ "QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"
+ "Numbrid ei klapi"
+ "Sisesta kahekohaline kood"
+ "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."
+ "Sisesta teises seadmes kuvatud number"
+ "Sinu teenusepakkuja ei toeta rakendust %1$s."
+ "%1$s pole toetatud"
+ "Sinu kasutajakonto teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil."
+ "QR-kood pole toetatud"
+ "Sisselogimine katkestati teises seadmes."
+ "Sisselogimispäring on tühistatud"
+ "Sisselogimine aegus. Palun proovi uuesti."
+ "Sisselogimine jäi etteantud aja jooksul tegemata"
+ "Ava %1$s teises seadmes"
+ "Vali %1$s"
+ "„Logi sisse QR-koodiga“"
+ "Skaneeri siin näidatud QR-koodi teise seadmega"
+ "Ava %1$s teises seadmes"
+ "Lauaarvuti"
+ "Laadin QR-koodi…"
+ "Nutiseade"
+ "Mis tüüpi seadet soovid siduda?"
+ "Palun proovi uuesti ja veendu, et sisestasid kahekohalise koodi õigesti. Kui numbrid ikka ei klapi, võta ühendust oma kasutajakonto teenusepakkujaga."
+ "Numbrid ei klapi"
+ "Turvalise ühenduse loomine uue seadmega ei õnnestunud. Sinu olemasolevad seadmed on jätkuvalt turvatud ja sa ei pea nende pärast muretsema."
+ "Mida järgmiseks teeme?"
+ "Kui see juhtumisi oli võrguühenduse viga, siis proovi uuesti QR-koodiga sisse logida"
+ "Kui sama probleem kordub, siis kasuta mõnda muud WiFi- või mobiilset andmedsideühendust"
+ "Kui see ka ei aita, siis logi sisse käsitsi"
+ "Ühendus pole turvaline"
+ "Sisselogimine katkestati teises seadmes."
+ "Sisselogimispäring on tühistatud"
+ "Sisselogimisest on teises seadmes keeldutud."
+ "Sisselogimisest on keeldutud"
+ "Sa ei pea enam midagi muud tegema."
+ "Sinu muu seade on juba sisse logitud"
+ "Sisselogimine aegus. Palun proovi uuesti."
+ "Sisselogimine jäi etteantud aja jooksul tegemata"
+ "Sinu teine seade ei toeta %s sisselogimist QR-koodiga.
+
+Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega."
+ "QR-kood pole toetatud"
+ "Sinu teenusepakkuja ei toeta rakendust %1$s."
+ "%1$s pole toetatud"
+ "Kasuta teises seadmes näidatavat QR-koodi"
+ "Proovi uuesti"
+ "Vale QR-kood"
+ "Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat"
+ "QR-koodi lugemiseks luba kaamerat kasutada"
+ "Tekkis ootamatu viga. Palun proovi uuesti."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-eu/translations.xml b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 0000000000..06cc0fd857
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,36 @@
+
+
+ "Eskaneatu QR kodea"
+ "Eskaneatu QR kodea gailu honekin"
+ "Eskaneatzeko prest"
+ "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria."
+ "%1$s ez da bateragarria"
+ "QR kodea ez da bateragarria"
+ "Saioa hasteko eskaera bertan behera utzi da beste gailuan"
+ "Saioa hasteko eskaera bertan behera utzi da"
+ "Saio-hasiera iraungi da. Saiatu berriro."
+ "Saio-hasiera ez da garaiz gauzatu."
+ "Hautatu %1$s"
+ "Ezin izan da konexio segururik ezarri gailu berriarekin. Lehendik dauden gailuak seguru daude oraindik ere eta ez duzu haietaz kezkatu beharrik."
+ "Orain zer?"
+ "Saiatu berriro QR kodearekin saioa hasten sare-arazo bat izan bada"
+ "Horrek ez badu funtzionatzen, hasi saioa eskuz"
+ "Konexioa ez da segurua"
+ "Saioa hasteko eskaera bertan behera utzi da beste gailuan"
+ "Saioa hasteko eskaera bertan behera utzi da"
+ "Saioa hasteari uko egin zaio beste dispositiboan."
+ "Saio-hasiera ukatu da"
+ "Saio-hasiera iraungi da. Saiatu berriro."
+ "Saio-hasiera ez da garaiz gauzatu."
+ "Beste gailua ez da bateragarria QR kodeak erabiliz %s(e)n saioa hastearekin.
+
+Saiatu saioa eskuz hasten, edo eskaneatu QR kodea beste gailu batean."
+ "QR kodea ez da bateragarria"
+ "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria."
+ "%1$s ez da bateragarria"
+ "Erabili beste gailuan agertzen den QR kodea."
+ "Saiatu berriro"
+ "QR kode okerra"
+ "Baimendu kameraren sarbidea QR kodea eskaneatzeko"
+ "Ustekabeko errore bat gertatu da. Saiatu berriro."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
new file mode 100644
index 0000000000..804fa653ad
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
@@ -0,0 +1,36 @@
+
+
+ "پویش کد پاس"
+ "پویش کد پاس با این افزاره"
+ "آمادهٔ پویش"
+ "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمیکند."
+ "%1$s پشتیبانی نمیشود"
+ "کد پاس پشتیبانی نمیشود"
+ "ورود روی افزارهٔ دیگر لغو شد."
+ "درخواست ورد لغو شد"
+ "ورود منقضی شد. لطفاً دوباره تلاش کنید."
+ "ورود در زمان معیّن کامل نشد"
+ "گزینش %1$s"
+ "نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزارههای موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."
+ "اکنون چه؟"
+ "ورود دستی در صورت کار نکردنش"
+ "اتّصال ناامن"
+ "ورود روی افزارهٔ دیگر لغو شد."
+ "درخواست ورد لغو شد"
+ "ورود به دست افزارهٔ دیگر رد شد."
+ "ورود رد شد"
+ "ورود منقضی شد. لطفاً دوباره تلاش کنید."
+ "ورود در زمان معیّن کامل نشد"
+ "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمیکند.
+
+آزمودن ورود دستی یا پویش کد پاس با افزارهای دیگر."
+ "کد پاس پشتیبانی نمیشود"
+ "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمیکند."
+ "%1$s پشتیبانی نمیشود"
+ "استفاده از کد پاس نشان داده روی افزارهٔ دیگر."
+ "تلاش دوباره"
+ "کد پاس اشتباه"
+ "برای ادامه باید اجازهٔ استفادهٔ %1$s از دوربین افزارهتان را بدهید."
+ "اجازهٔ دسترسی دوربین برای پویش کد پاس"
+ "خطایی غیرمنتظره رخ داد. لطفاً دوباره تلاش کنید."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
new file mode 100644
index 0000000000..3ed954a842
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skannaa QR-koodi"
+ "Skannaa QR-koodi tällä laitteella"
+ "Valmis skannaamaan"
+ "Palveluntarjoajasi ei tue %1$s -sovellusta"
+ "%1$s -sovellusta ei tueta"
+ "QR-koodia ei tueta"
+ "Kirjautuminen peruutettiin toisella laitteella."
+ "Kirjautumispyyntö peruutettu"
+ "Kirjautuminen vanhentui. Yritä uudelleen."
+ "Kirjautumista ei suoritettu ajoissa"
+ "Valitse %1$s"
+ "Turvallista yhteyttä uuteen laitteeseen ei voitu muodostaa. Olemassa olevat laitteesi ovat edelleen turvassa, eikä sinun tarvitse huolehtia niistä."
+ "Mitä nyt?"
+ "Yritä kirjautua sisään uudelleen QR-koodilla, jos kyseessä oli verkko-ongelma"
+ "Jos kohtaat saman ongelman, kokeile toista wifi-verkkoa tai käytä mobiilidataa wifi-yhteyden sijaan"
+ "Jos tämä ei auta, kirjaudu sisään manuaalisesti"
+ "Yhteys ei ole turvallinen"
+ "Kirjautuminen peruutettiin toisella laitteella."
+ "Kirjautumispyyntö peruutettu"
+ "Kirjautuminen hylättiin toisella laitteella."
+ "Kirjautuminen hylätty"
+ "Kirjautuminen vanhentui. Yritä uudelleen."
+ "Kirjautumista ei suoritettu ajoissa"
+ "Toinen laitteesi ei tue kirjautumista %s -sovellukseen QR-koodilla.
+
+Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella."
+ "QR-koodia ei tueta"
+ "Palveluntarjoajasi ei tue %1$s -sovellusta"
+ "%1$s -sovellusta ei tueta"
+ "Käytä toisessa laitteessa näkyvää QR-koodia."
+ "Yritä uudelleen"
+ "Väärä QR-koodi"
+ "Jatkaaksesi sinun on annettava lupa %1$s -sovellukselle käyttää laitteesi kameraa."
+ "Salli lupa kameraan QR-koodin skannaamiseksi"
+ "Tapahtui odottamaton virhe. Yritä uudelleen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..0c91dca7a1
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,55 @@
+
+
+ "Scannez le code QR"
+ "Ouvrir %1$s sur un ordinateur"
+ "Scanner le code QR avec cet appareil"
+ "Prêt à scanner"
+ "Ouvrir %1$s sur un ordinateur pour obtenir le code QR"
+ "Les nombres ne correspondent pas"
+ "Saisissez le code à 2 chiffres"
+ "Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée."
+ "Saisir le nombre affiché sur votre autre appareil"
+ "Votre fournisseur de compte ne supporte pas %1$s."
+ "%1$s n’est pas supporté"
+ "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à l’aide d’un code QR."
+ "Code QR non supporté"
+ "La connexion a été annulée sur l’autre appareil."
+ "Demande de connexion annulée"
+ "Connexion expirée. Veuillez essayer à nouveau."
+ "La connexion a pris trop de temps."
+ "Ouvrez %1$s sur l’autre appareil"
+ "Choisissez %1$s"
+ "« Se connecter avec un code QR »"
+ "Scannez ce code QR avec l’autre appareil."
+ "Ouvrez %1$s sur l’autre appareil"
+ "Ordinateur de bureau"
+ "Chargement du code QR…"
+ "Appareil mobile"
+ "Quel type d’appareil souhaitez-vous associer ?"
+ "Veuillez réessayer et assurez-vous d’avoir saisi correctement le code à 2 chiffres. Si les chiffres ne correspondent toujours pas, veuillez contacter votre fournisseur de compte."
+ "Les nombres ne correspondent pas"
+ "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier."
+ "Et maintenant ?"
+ "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau"
+ "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi"
+ "Si cela ne fonctionne pas, connectez-vous manuellement"
+ "La connexion n’est pas sécurisée"
+ "La connexion a été annulée sur l’autre appareil."
+ "Demande de connexion annulée"
+ "La connexion a été refusée sur l’autre appareil."
+ "Connexion refusée"
+ "Vous n’avez rien d’autre à faire."
+ "Votre autre appareil est déjà connecté"
+ "Connexion expirée. Veuillez essayer à nouveau."
+ "La connexion a pris trop de temps."
+ "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil."
+ "Code QR non supporté"
+ "Votre fournisseur de compte ne supporte pas %1$s."
+ "%1$s n’est pas supporté"
+ "Scannez le code QR affiché sur l’autre appareil."
+ "Essayer à nouveau"
+ "Code QR erroné"
+ "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."
+ "Autoriser l’usage de la caméra pour scanner le code QR"
+ "Une erreur inattendue s’est produite. Veuillez réessayer."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..20c194ef93
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Skeniraj QR kod"
+ "Otvorite %1$s na prijenosnom ili stolnom računalu"
+ "Skenirajte QR kod ovim uređajem"
+ "Spremno za skeniranje"
+ "Otvorite %1$s na stolnom računalu kako biste dobili QR kod"
+ "Brojevi se ne podudaraju"
+ "Unesite dvoznamenkasti kod"
+ "Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."
+ "Unesite broj prikazan na vašem drugom uređaju"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Vaš davatelj usluga računa ne podržava prijavu na novi uređaj pomoću QR koda."
+ "QR kod nije podržan"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Otvorite %1$s na drugom uređaju"
+ "Odaberite %1$s"
+ "“Prijavi se pomoću QR koda”"
+ "Skenirajte ovdje prikazani QR kod drugim uređajem"
+ "Otvorite %1$s na drugom uređaju"
+ "Stolno računalo"
+ "Učitavanje QR koda…"
+ "Mobilni uređaj"
+ "Koju vrstu uređaja želite povezati?"
+ "Pokušajte ponovno i provjerite jeste li ispravno unijeli dvoznamenkasti kod. Ako se brojevi i dalje ne podudaraju, obratite se davatelju usluge računa."
+ "Brojevi se ne podudaraju"
+ "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih."
+ "Što sad?"
+ "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom"
+ "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja."
+ "Ako to ne uspije, prijavite se ručno"
+ "Veza nije sigurna"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je odbijena na drugom uređaju."
+ "Prijava je odbijena"
+ "Ne morate ništa drugo napraviti."
+ "Vaš drugi uređaj već je prijavljen"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda.
+
+Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem."
+ "QR kod nije podržan"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Upotrijebite QR kod prikazan na drugom uređaju."
+ "Pokušajte ponovno"
+ "Pogrešan QR kod"
+ "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja."
+ "Dopustite pristup kameri kako biste mogli skenirati QR kod"
+ "Došlo je do neočekivane pogreške. Pokušajte ponovno."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..b06d7e2fd6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Olvassa be a QR-kódot"
+ "Olvassa be a QR-kódot ezzel az eszközzel"
+ "Készen áll a beolvasásra"
+ "A fiókszolgáltatója nem támogatja az %1$s-et."
+ "Az %1$s nem támogatott"
+ "A QR-kód nem támogatott"
+ "A bejelentkezést megszakították a másik eszközön."
+ "Bejelentkezési kérés törölve"
+ "A bejelentkezés lejárt. Próbálja újra."
+ "A bejelentkezés nem fejeződött be időben"
+ "Válassza ezt: %1$s"
+ "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."
+ "Most mi lesz?"
+ "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."
+ "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"
+ "Ha ez nem működik, jelentkezzen be kézileg"
+ "A kapcsolat nem biztonságos"
+ "A bejelentkezést megszakították a másik eszközön."
+ "Bejelentkezési kérés törölve"
+ "A bejelentkezést elutasították a másik eszközön."
+ "A bejelentkezés elutasítva"
+ "A bejelentkezés lejárt. Próbálja újra."
+ "A bejelentkezés nem fejeződött be időben"
+ "A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.
+
+Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel."
+ "A QR-kód nem támogatott"
+ "A fiókszolgáltatója nem támogatja az %1$s-et."
+ "Az %1$s nem támogatott"
+ "Használja a másik eszközön látható QR-kódot."
+ "Próbálja újra"
+ "Hibás QR-kód"
+ "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."
+ "Engedélyezze a kamera elérését a QR-kód beolvasásához"
+ "Váratlan hiba történt. Próbálja meg újra."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-in/translations.xml b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
new file mode 100644
index 0000000000..20badba9ba
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Pindai kode QR"
+ "Pindai kode QR dengan perangkat ini"
+ "Siap untuk memindai"
+ "Penyedia akun Anda tidak mendukung %1$s."
+ "%1$s tidak didukung"
+ "Kode QR tidak didukung"
+ "Proses masuk dibatalkan di perangkat lain."
+ "Permintaan masuk dibatalkan"
+ "Masa masuk kedaluwarsa. Silakan coba lagi."
+ "Proses masuk tidak selesai tepat waktu"
+ "Pilih %1$s"
+ "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."
+ "Apa sekarang?"
+ "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"
+ "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"
+ "Jika tidak berhasil, masuk secara manual"
+ "Koneksi tidak aman"
+ "Proses masuk dibatalkan di perangkat lain."
+ "Permintaan masuk dibatalkan"
+ "Proses masuk ditolak di perangkat lain."
+ "Proses masuk ditolak"
+ "Masa masuk kedaluwarsa. Silakan coba lagi."
+ "Proses masuk tidak selesai tepat waktu"
+ "Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
+
+Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."
+ "Kode QR tidak didukung"
+ "Penyedia akun Anda tidak mendukung %1$s."
+ "%1$s tidak didukung"
+ "Gunakan kode QR yang ditampilkan di perangkat lain."
+ "Coba lagi"
+ "Kode QR salah"
+ "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."
+ "Izinkan akses kamera untuk memindai kode QR"
+ "Terjadi kesalahan tak terduga. Silakan coba lagi."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-it/translations.xml b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..0cf548c19b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scansiona il codice QR"
+ "Scansiona il codice QR con questo dispositivo"
+ "Pronto per la scansione"
+ "Il tuo fornitore di account non supporta %1$s."
+ "%1$s non supportato"
+ "Codice QR non supportato"
+ "L\'accesso è stato annullato sull\'altro dispositivo."
+ "Richiesta di accesso annullata"
+ "L\'accesso è scaduto. Riprova."
+ "L\'accesso non è stato completato in tempo"
+ "Seleziona %1$s"
+ "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro."
+ "E adesso?"
+ "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete."
+ "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi."
+ "Se il problema persiste, accedi manualmente"
+ "La connessione non è sicura"
+ "L\'accesso è stato annullato sull\'altro dispositivo."
+ "Richiesta di accesso annullata"
+ "L\'accesso è stato rifiutato sull\'altro dispositivo."
+ "Accesso rifiutato"
+ "L\'accesso è scaduto. Riprova."
+ "L\'accesso non è stato completato in tempo"
+ "L\'altro dispositivo non supporta l\'accesso a %s con un codice QR.
+
+Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo."
+ "Codice QR non supportato"
+ "Il tuo fornitore di account non supporta %1$s."
+ "%1$s non supportato"
+ "Usa il codice QR mostrato sull\'altro dispositivo."
+ "Riprova"
+ "Codice QR sbagliato"
+ "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo."
+ "Consenti l\'accesso alla fotocamera per la scansione del codice QR"
+ "Si è verificato un errore inatteso. Riprova."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ka/translations.xml b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml
new file mode 100644
index 0000000000..f378e585ce
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "ხელახლა ცდა"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ko/translations.xml b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
new file mode 100644
index 0000000000..6af49fd3cf
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR 코드를 스캔하세요"
+ "이 기기로 QR 코드를 스캔하세요."
+ "스캔 준비 완료"
+ "귀하의 계정 제공자는 지원하지 않습니다 %1$s ."
+ "%1$s 지원되지 않습니다"
+ "QR 코드는 지원되지 않습니다"
+ "다른 기기에서 로그인이 취소되었습니다."
+ "로그인 요청이 취소되었습니다"
+ "로그인이 만료되었습니다. 다시 시도해 주세요."
+ "로그인 시간이 초과되었습니다."
+ "선택 %1$s"
+ "새 장치에 안전하게 연결할 수 없습니다. 기존 장치는 여전히 안전하므로 걱정할 필요가 없습니다."
+ "이제 어떻게 해야 할까?"
+ "네트워크 문제로 인해 로그인에 실패한 경우 QR 코드로 다시 로그인해 보세요."
+ "동일한 문제를 겪으신 경우 다른 Wi-Fi 네트워크를 사용해 보거나 Wi-Fi 대신 모바일 데이터를 사용해 보세요."
+ "만약 작동하지 않는 경우, 수동으로 로그인하세요."
+ "연결이 안전하지 않습니다"
+ "다른 기기에서 로그인이 취소되었습니다."
+ "로그인 요청이 취소되었습니다"
+ "다른 기기에서 로그인이 거부되었습니다."
+ "로그인 거부됨"
+ "로그인이 만료되었습니다. 다시 시도해 주세요."
+ "로그인 시간이 초과되었습니다."
+ "다른 기기에서는 QR 코드로 %s 에 로그인할 수 없습니다.
+
+수동으로 로그인하거나 다른 기기로 QR 코드를 스캔해 보세요."
+ "QR 코드는 지원되지 않습니다"
+ "귀하의 계정 제공자는 지원하지 않습니다 %1$s ."
+ "%1$s 지원되지 않습니다"
+ "다른 기기에 표시된 QR 코드를 사용하세요."
+ "다시 시도하기"
+ "잘못된 QR 코드"
+ "계속하려면 %1$s 가 기기의 카메라를 사용할 수 있도록 권한을 부여해야 합니다."
+ "카메라 액세스를 허용하여 QR 코드를 스캔하세요"
+ "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
new file mode 100644
index 0000000000..af3559d3e8
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skann QR-koden"
+ "Skann QR-koden med denne enheten"
+ "Klar til å skanne"
+ "Kontotilbyderen din støtter ikke %1$s."
+ "%1$s støttes ikke"
+ "QR-kode støttes ikke"
+ "Påloggingen ble kansellert på den andre enheten."
+ "Påloggingsforespørsel kansellert"
+ "Påloggingen er utløpt. Vennligst prøv igjen."
+ "Påloggingen ble ikke fullført i tide"
+ "Velg %1$s"
+ "En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem."
+ "Hva nå?"
+ "Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem"
+ "Hvis du støter på det samme problemet, kan du prøve et annet wifi-nettverk eller bruke mobildata i stedet for wifi"
+ "Hvis det ikke fungerer, kan du logge på manuelt"
+ "Forbindelsen er ikke sikker"
+ "Påloggingen ble kansellert på den andre enheten."
+ "Påloggingsforespørsel kansellert"
+ "Påloggingen ble avvist på den andre enheten."
+ "Pålogging avslått"
+ "Påloggingen er utløpt. Vennligst prøv igjen."
+ "Påloggingen ble ikke fullført i tide"
+ "Den andre enheten din støtter ikke pålogging på %s med en QR-kode.
+
+Prøv å logge på manuelt, eller skann QR-koden med en annen enhet."
+ "QR-kode støttes ikke"
+ "Kontotilbyderen din støtter ikke %1$s."
+ "%1$s støttes ikke"
+ "Bruk QR-koden som vises på den andre enheten."
+ "Prøv igjen"
+ "Feil QR-kode"
+ "Du må gi tillatelse til at %1$s kan bruke enhetens kamera for å fortsette."
+ "Tillat kameratilgang for å skanne QR-koden"
+ "Det oppstod en uventet feil. Prøv igjen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-nl/translations.xml b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..407a470e48
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scan de QR-code"
+ "Scan de QR-code met dit apparaat"
+ "Klaar om te scannen"
+ "Je accountprovider ondersteunt geen %1$s."
+ "%1$s wordt niet ondersteund"
+ "QR-code wordt niet ondersteund"
+ "De aanmelding is geannuleerd op het andere apparaat."
+ "Login verzoek geannuleerd"
+ "Aanmelden is verlopen. Probeer het opnieuw."
+ "De aanmelding was niet op tijd voltooid"
+ "Selecteer %1$s"
+ "Er kon geen beveiligde verbinding worden gemaakt met het nieuwe apparaat. Je bestaande apparaten zijn nog steeds veilig en je hoeft je daarover geen zorgen te maken."
+ "Wat nu?"
+ "Probeer opnieuw in te loggen met een QR-code voor het geval dit een netwerkprobleem was"
+ "Als je hetzelfde probleem ondervindt, probeer dan een ander wifi-netwerk of gebruik je mobiele data in plaats van wifi."
+ "Als dat niet werkt, log dan handmatig in"
+ "Verbinding niet veilig"
+ "De aanmelding is geannuleerd op het andere apparaat."
+ "Login verzoek geannuleerd"
+ "De aanmelding is geweigerd op het andere apparaat."
+ "Aanmelden geweigerd"
+ "Aanmelden is verlopen. Probeer het opnieuw."
+ "De aanmelding was niet op tijd voltooid"
+ "Jouw andere apparaat ondersteunt geen inloggen op %s met een QR code.
+
+Probeer handmatig in te loggen, of scan de QR code met een ander apparaat."
+ "QR-code wordt niet ondersteund"
+ "Je accountprovider ondersteunt geen %1$s."
+ "%1$s wordt niet ondersteund"
+ "Gebruik de QR-code die op het andere apparaat wordt weergegeven."
+ "Probeer het opnieuw"
+ "Verkeerde QR-code"
+ "Je moet %1$s toestemming geven om de camera van je apparaat te gebruiken om verder te gaan."
+ "Cameratoegang toestaan om de QR-code te scannen"
+ "Er is een onverwachte fout opgetreden. Probeer het opnieuw."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pl/translations.xml b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..4db42a2a49
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skanuj kod QR"
+ "Zeskanuj kod QR za pomocą tego urządzenia"
+ "Gotowy do skanowania"
+ "Twój dostawca konta nie obsługuje %1$s."
+ "%1$s nie jest wspierany"
+ "Kod QR nie jest wspierany"
+ "Logowanie zostało anulowane na drugim urządzeniu."
+ "Prośba o logowanie została anulowana"
+ "Logowanie wygasło. Spróbuj ponownie."
+ "Logowanie nie zostało ukończone na czas"
+ "Wybierz %1$s"
+ "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"
+ "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"
+ "Użyj kodu QR widocznego na drugim urządzeniu."
+ "Spróbuj ponownie"
+ "Błędny kod QR"
+ "Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować."
+ "Zezwól na dostęp do kamery, aby zeskanować kod QR"
+ "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..f11bdc6e6d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Leia o código QR"
+ "Abra o %1$s em um computador"
+ "Leia o código QR com este dispositivo"
+ "Pronto para ler"
+ "Abra o %1$s em um computador para receber o código QR"
+ "Os números não conferem"
+ "Digite o código de 2 dígitos"
+ "Isso verificará que a conexão com o seu outro dispositivo é segura."
+ "Digite o número exibido no outro dispositivo"
+ "Seu provedor de conta não tem suporte ao %1$s."
+ "%1$s não suportado"
+ "O seu provedor de conta não tem suporte a autenticação de dispositivos novos com um código QR."
+ "Código QR não suportado"
+ "A entrada foi cancelada no outro dispositivo."
+ "Solicitação de entrada foi cancelada"
+ "O processo de entrada expirou. Tente novamente."
+ "A entrada não foi concluída a tempo"
+ "Abra o %1$s no outro dispositivo"
+ "Selecione %1$s"
+ "\"Entrar com código QR\""
+ "Leia o código QR exibido aqui com o outro dispositivo"
+ "Abra o %1$s no outro dispositivo"
+ "Computador"
+ "Carregando código QR…"
+ "Dispositivo móvel"
+ "Que tipo de dispositivo você deseja vincular?"
+ "Tente novamente e certifique-se que digitou o código de 2 dígitos corretamente. Se os números ainda não conferirem, entre em contato com o provedor da sua conta."
+ "Os números não conferem"
+ "Não foi possível estabelecer uma conexão segura com o novo dispositivo. Seus dispositivos existentes ainda estão seguros e você não precisa se preocupar com eles."
+ "E agora?"
+ "Tente entrar novamente com um código QR caso seja um problema de rede"
+ "Se o problema persistir, tente uma rede Wi-Fi diferente ou use seus dados móveis em vez de Wi-Fi"
+ "Se isso não funcionar, entre manualmente"
+ "Conexão insegura"
+ "A entrada foi cancelada no outro dispositivo."
+ "Solicitação de entrada foi cancelada"
+ "A entrada foi recusada no outro dispositivo."
+ "Entrada recusada"
+ "Você não precisa fazer mais nada."
+ "O seu outro dispositivo já está conectado"
+ "O processo de entrada expirou. Tente novamente."
+ "A entrada não foi concluída a tempo"
+ "Seu outro dispositivo não tem suporte a entrar no %s com um código QR.
+
+Tente entrar manualmente ou ler o código QR com outro dispositivo."
+ "Código QR não suportado"
+ "Seu provedor de conta não tem suporte ao %1$s."
+ "%1$s não suportado"
+ "Use o código QR exibido no outro dispositivo."
+ "Tente novamente"
+ "Código QR errado"
+ "Você deve permitir que o %1$s use a câmera do seu dispositivo para continuar."
+ "Permita o acesso à câmera para ler o código QR"
+ "Ocorreu um erro inesperado. Tente novamente."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pt/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
new file mode 100644
index 0000000000..da6da08f38
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Ler o código QR"
+ "Lê o código QR com este dispositivo"
+ "Pronto para ler"
+ "O teu operador de conta não suporta %1$s."
+ "%1$s não suportado"
+ "Código QR não suportado"
+ "O início de sessão foi cancelado no outro dispositivo."
+ "Pedido de início de sessão cancelado"
+ "O início de sessão expirou. Por favor, tenta novamente."
+ "O início de sessão não foi concluído a tempo"
+ "Seleciona %1$s"
+ "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles."
+ "E agora?"
+ "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede"
+ "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis."
+ "Se isso não funcionar, inicia sessão manualmente"
+ "Ligação insegura"
+ "O início de sessão foi cancelado no outro dispositivo."
+ "Pedido de início de sessão cancelado"
+ "O início de sessão foi rejeitado no outro dispositivo."
+ "Início de sessão rejeitado"
+ "O início de sessão expirou. Por favor, tenta novamente."
+ "O início de sessão não foi concluído a tempo"
+ "O teu outro dispositivo não suporta o início de sessão na %s com um código QR.
+
+Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro dispositivo."
+ "Código QR não suportado"
+ "O teu operador de conta não suporta %1$s."
+ "%1$s não suportado"
+ "Lê o código QR apresentado no outro dispositivo."
+ "Tentar novamente"
+ "Código QR inválido"
+ "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo."
+ "Permitir o acesso à câmara para ler o código QR"
+ "Ocorreu um erro inesperado. Tenta novamente."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..f1a4f3db59
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,54 @@
+
+
+ "Scanați codul QR"
+ "Deschide %1$s pe un laptop sau un computer desktop"
+ "Scanați codul QR cu acest dispozitiv"
+ "Gata de scanare"
+ "Deschide %1$s pe un computer desktop pentru a obține codul QR"
+ "Numerele nu se potrivesc"
+ "Introduceți codul de 2 cifre"
+ "Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."
+ "Introduceți numărul afișat pe celălalt dispozitiv"
+ "Furnizorul dumneavoastră de cont nu acceptă %1$s."
+ "%1$s nu este acceptat"
+ "Furnizorul contului dumneavoastră nu acceptă conectarea la un dispozitiv nou cu un cod QR."
+ "Formatul codului QR nu este acceptat."
+ "Autentificarea a fost anulată de pe celălalt dispozitiv."
+ "Cererea de autentificare a fost anulată"
+ "Autentificarea a expirat. Vă rugăm să încercați din nou."
+ "Autentificarea nu a fost finalizată la timp"
+ "Deschideți %1$s pe celălalt dispozitiv"
+ "Selectați %1$s"
+ "“Conectați-vă cu un cod QR”"
+ "Scanați codul QR afișat aici cu celălalt dispozitiv."
+ "Deschideți %1$s pe celălalt dispozitiv"
+ "Calculator desktop"
+ "Se încarcă codul QR…"
+ "Dispozitiv mobil"
+ "Ce tip de dispozitiv doriți să conectați?"
+ "Numerele nu se potrivesc"
+ "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele."
+ "Și acum?"
+ "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea."
+ "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi."
+ "Dacă nu funcționează, conectați-vă manual"
+ "Conexiunea nu este sigură"
+ "Autentificarea a fost anulată de pe celălalt dispozitiv."
+ "Cererea de autentificare a fost anulată"
+ "Autentificarea a fost refuzată pe celălalt dispozitiv."
+ "Autentificarea a fost refuzată"
+ "Autentificarea a expirat. Vă rugăm să încercați din nou."
+ "Autentificarea nu a fost finalizată la timp"
+ "Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR.
+
+Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv."
+ "Formatul codului QR nu este acceptat."
+ "Furnizorul dumneavoastră de cont nu acceptă %1$s."
+ "%1$s nu este acceptat"
+ "Utilizați codul QR afișat pe celălalt dispozitiv."
+ "Încercați din nou"
+ "Cod QR greșit"
+ "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua."
+ "Permiteți accesul la cameră pentru a scana codul QR"
+ "A apărut o eroare neașteptată. Vă rugăm să încercați din nou."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..16ed8eeb37
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Сканировать QR-код"
+ "Отсканируйте QR-код с помощью этого устройства"
+ "Готово к сканированию"
+ "Поставщик учетной записи не поддерживает %1$s."
+ "%1$s не поддерживается"
+ "QR-код не поддерживается"
+ "Вход на другом устройстве был отменен."
+ "Запрос на вход отменен"
+ "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Вход в систему не был выполнен вовремя"
+ "Выберите %1$s"
+ "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."
+ "Что теперь?"
+ "Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением"
+ "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"
+ "Если это не помогло, войдите вручную"
+ "Соединение не защищено"
+ "Вход на другом устройстве был отменен."
+ "Запрос на вход отменен"
+ "Вход в систему был отклонен на другом устройстве."
+ "Вход отклонен"
+ "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Вход в систему не был выполнен вовремя"
+ "Другое устройство не поддерживает вход в %s с помощью QR-кода.
+
+Попробуйте войти вручную или отсканируйте QR-код на другом устройстве."
+ "QR-код не поддерживается"
+ "Поставщик учетной записи не поддерживает %1$s."
+ "%1$s не поддерживается"
+ "Используйте QR-код, показанный на другом устройстве."
+ "Повторить попытку"
+ "Неверный QR-код"
+ "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."
+ "Разрешите доступ к камере для сканирования QR-кода"
+ "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-sk/translations.xml b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..9617df3acc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Naskenovať QR kód"
+ "Naskenujte QR kód pomocou tohto zariadenia"
+ "Pripravené na skenovanie"
+ "Poskytovateľ vášho účtu nepodporuje %1$s."
+ "%1$s nie je podporovaný"
+ "QR kód nie je podporovaný"
+ "Prihlásenie bolo zrušené na druhom zariadení."
+ "Žiadosť o prihlásenie bola zrušená"
+ "Platnosť prihlásenia vypršala. Skúste to prosím znova."
+ "Prihlásenie nebolo včas dokončené"
+ "Vyberte %1$s"
+ "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."
+ "Čo teraz?"
+ "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"
+ "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"
+ "Ak to nefunguje, prihláste sa manuálne"
+ "Pripojenie nie je bezpečené"
+ "Prihlásenie bolo zrušené na druhom zariadení."
+ "Žiadosť o prihlásenie bola zrušená"
+ "Prihlásenie bolo zamietnuté na druhom zariadení."
+ "Prihlásenie bolo odmietnuté"
+ "Platnosť prihlásenia vypršala. Skúste to prosím znova."
+ "Prihlásenie nebolo včas dokončené"
+ "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu.
+
+Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia."
+ "QR kód nie je podporovaný"
+ "Poskytovateľ vášho účtu nepodporuje %1$s."
+ "%1$s nie je podporovaný"
+ "Použite QR kód zobrazený na druhom zariadení."
+ "Skúste to znova"
+ "Nesprávny QR kód"
+ "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."
+ "Povoľte prístup k fotoaparátu na naskenovanie QR kódu"
+ "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-sv/translations.xml b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
new file mode 100644
index 0000000000..8a1bef434a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skanna QR-koden"
+ "Skanna QR-koden med den här enheten"
+ "Redo att skanna"
+ "Din kontoleverantör stöder inte %1$s."
+ "%1$s stöds inte"
+ "QR-kod stöds inte"
+ "Inloggningen avbröts på den andra enheten."
+ "Inloggningsförfrågan avbröts"
+ "Inloggningen har löpt ut. Vänligen försök igen."
+ "Inloggningen slutfördes inte i tid"
+ "Välj %1$s"
+ "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"
+ "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"
+ "Använd QR-koden som visas på den andra enheten."
+ "Försök igen"
+ "Fel QR-kod"
+ "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"
+ "Ett oväntat fel inträffade. Vänligen försök igen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-tr/translations.xml b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 0000000000..2d3bebcad8
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR kodunu tara"
+ "QR kodunu bu cihazla tarayın"
+ "Taramaya hazır"
+ "Hesap sağlayıcınız %1$s desteklemiyor."
+ "%1$s desteklenmiyor"
+ "QR kodu desteklenmiyor"
+ "Oturum açma işlemi diğer cihazda iptal edildi."
+ "Oturum açma isteği iptal edildi"
+ "Oturum açma süresi doldu. Lütfen tekrar deneyin."
+ "Oturum açma işlemi zamanında tamamlanmadı"
+ "Seç %1$s"
+ "Yeni cihaza güvenli bir bağlantı kurulamadı. Mevcut cihazlarınız hala güvende ve onlar için endişelenmenize gerek yok."
+ "Şimdi ne olacak?"
+ "Bunun bir ağ sorunu olması ihtimaline karşı bir QR koduyla tekrar oturum açmayı deneyin"
+ "Aynı sorunla karşılaşırsanız, farklı bir wifi ağı deneyin veya wifi yerine mobil verinizi kullanın"
+ "Bu işe yaramazsa, manuel olarak oturum açın"
+ "Bağlantı güvenli değil"
+ "Oturum açma işlemi diğer cihazda iptal edildi."
+ "Oturum açma isteği iptal edildi"
+ "Diğer cihazda oturum açma işlemi reddedildi."
+ "Oturum açma reddedildi"
+ "Oturum açma süresi doldu. Lütfen tekrar deneyin."
+ "Oturum açma işlemi zamanında tamamlanmadı"
+ "Diğer cihazınız %s QR koduyla oturum açmayı desteklemiyor.
+
+Manuel olarak oturum açmayı deneyin veya QR kodunu başka bir cihazla tarayın."
+ "QR kodu desteklenmiyor"
+ "Hesap sağlayıcınız %1$s desteklemiyor."
+ "%1$s desteklenmiyor"
+ "Diğer cihazda gösterilen QR kodunu kullan."
+ "Tekrar deneyin"
+ "Yanlış QR kodu"
+ "Devam etmek için %1$s cihazınızın kamerasını kullanmasına izin vermeniz gerekir."
+ "QR kodunu taramak için kamera erişimine izin verin"
+ "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-uk/translations.xml b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..e7104a914d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Зіскануйте QR-код"
+ "Зіскануйте QR-код цим пристроєм"
+ "Готовий до сканування"
+ "Постачальник вашого облікового запису не підтримує %1$s."
+ "%1$s не підтримується"
+ "QR-код не підтримується"
+ "Вхід було скасовано на іншому пристрої."
+ "Запит на вхід скасовано"
+ "Термін входу сплив. Будь ласка, спробуйте ще раз."
+ "Вхід не було завершено вчасно"
+ "Виберіть %1$s"
+ "Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."
+ "Що тепер?"
+ "Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею"
+ "Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi"
+ "Якщо це не спрацює, увійдіть вручну"
+ "З\'єднання не безпечне"
+ "Вхід було скасовано на іншому пристрої."
+ "Запит на вхід скасовано"
+ "Вхід був відхилений на іншому пристрої."
+ "Вхід відхилено"
+ "Термін входу сплив. Будь ласка, спробуйте ще раз."
+ "Вхід не було завершено вчасно"
+ "Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду.
+
+Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою."
+ "QR-код не підтримується"
+ "Постачальник вашого облікового запису не підтримує %1$s."
+ "%1$s не підтримується"
+ "Використовуйте QR-код, показаний на іншому пристрої."
+ "Спробуйте ще раз"
+ "Неправильний QR-код"
+ "Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити."
+ "Надайте доступ до камери, щоб сканувати QR-код"
+ "Сталася несподівана помилка. Будь ласка, спробуйте ще раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ur/translations.xml b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 0000000000..54d2c2e401
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "کیو آر رمز مسح ضوئی کریں"
+ "اس آلے کے ساتھ کیو آر رمز مسح ضوئی کریں"
+ "مسح ضوئی کیلئے تیار"
+ "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔"
+ "%1$s تعاون یافتہ نہیں"
+ "کر رمز غیر تعاون یافتہ"
+ "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔"
+ "دخول کی درخواست منسوخ"
+ "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔"
+ "دخول وقت پر مکمل نہیں ہوا تھا"
+ "%1$s منتخب کریں"
+ "نئے آلے سے محفوظ اتصال نہیں بنایا جا سکا۔ آپ کے موجودہ آلات اب بھی محفوظ ہیں اور آپ کو ان کے بارے میں فکر کرنے کی ضرورت نہیں ہے۔"
+ "اب کیا؟"
+ "اگر یہ شبکہ کا مسئلہ تھا تو کیو آر رمز کے ساتھ دوبارہ داخل ہونے کی کوشش کریں۔"
+ "اگر آپ کو بھی یہی مسئلہ درپیش ہو، تو کوئی دوسرا وائی فائی شبکہ آزمائیں یا وائی فائی کے بجائے اپنے محمول بیانات استعمال کریں۔"
+ "اگر یہ کام نہ کرے، تو دستی طور پر داخل ہوں"
+ "اتصال محفوظ نہیں"
+ "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔"
+ "دخول کی درخواست منسوخ"
+ "دوسرے آلہ پر دخول کو مسترد کر دیا گیا تھا۔"
+ "دخول مسترد کیا گیا"
+ "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔"
+ "دخول وقت پر مکمل نہیں ہوا تھا"
+ "آپ کا دوسرا آلہ کیو آر رمز کے ساتھ %s میں دخول کا تعاون نہیں کرتا۔
+
+دستی طور پر داخل ہونے کی کوشش کریں ، یا کسی دوسرے آلے سے کیو آر رمز مسح ضوئی کریں۔"
+ "کر رمز غیر تعاون یافتہ"
+ "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔"
+ "%1$s تعاون یافتہ نہیں"
+ "دوسرے آلے پر دکھایا گیا کیو آر رمز استعمال کریں۔"
+ "دوبارہ کوشش کریں"
+ "غلط کیو آر رمز"
+ "جاری رکھنے کے لیے آپ %1$s کو اپنے آلے کا تصویرگر استعمال کرنے کی اجازت دینے کی ضرورت ہے۔"
+ "کیو آر رمز کو مسح ضوئی کرنے کے لئے تصویرگر تک رسائی کی اجازت دیں"
+ "ایک غیر متوقع نقص واقع ہوا۔ برائے مہربانی دوبارہ کوشش کریں۔"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-uz/translations.xml b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..d2f3b7a865
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR kodni skanerlash"
+ "Bu qurilma bilan QR kodni skanerlang"
+ "Skanerlashga tayyor"
+ "Hisob provayderingiz %1$s bilan ishlamaydi."
+ "%1$s qoʻllab-quvvatlanmaydi"
+ "QR kod qoʻllab-quvvatlanmaydi"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish soʻrovi bekor qilindi"
+ "Kirish muddati tugagan. Iltimos, qayta urinib koʻring."
+ "Kirish oʻz vaqtida tugallanmagan"
+ "%1$sʼni tanlang"
+ "Yangi qurilmaga xavfsiz ulanish amalga oshirilmadi. Mavjud qurilmalaringiz hali ham xavfsiz va ular haqida qaygʻurishingiz shart emas."
+ "Endi nima?"
+ "Agar bu tarmoq muammosi boʻlsa, QR kod bilan qayta kiring"
+ "Xuddi shu muammoga duch kelsangiz, boshqa wifi tarmogʻini sinang yoki wifi oʻrniga mobil internetdan foydalaning"
+ "Agar bunisi ishlamasa, oddiy usulda kiring"
+ "Ulanish xavfsiz emas"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish soʻrovi bekor qilindi"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish rad etildi"
+ "Kirish muddati tugagan. Iltimos, qayta urinib koʻring."
+ "Kirish oʻz vaqtida tugallanmagan"
+ "Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi.
+
+Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang."
+ "QR kod qoʻllab-quvvatlanmaydi"
+ "Hisob provayderingiz %1$s bilan ishlamaydi."
+ "%1$s qoʻllab-quvvatlanmaydi"
+ "Narigi qurilmada koʻrsatilgan QR koddan foydalaning."
+ "Qayta urinib ko\'ring"
+ "QR kod notoʻgʻri"
+ "Davom etish uchun %1$s qurilmangiz kamerasidan foydalanishiga ruxsat berishingiz kerak."
+ "QR kodni skanerlash uchun kameraga ruxsat bering"
+ "Kutilmagan xatolik yuz berdi. Qayta urining."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 0000000000..9b3cd5ead5
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "掃描 QR code"
+ "使用此裝置掃描 QR code"
+ "準備掃描"
+ "您的帳號提供者不支援 %1$s。"
+ "不支援 %1$s"
+ "不支援 QR code"
+ "已在其他裝置上取消登入。"
+ "已取消登入請求"
+ "登入已過期。請再試一次。"
+ "未及時完成登入"
+ "選取 %1$s"
+ "無法與新裝置建立安全連線。您現有的裝置仍然安全,您不必擔心它們。"
+ "現在怎麼辦?"
+ "嘗試再次使用 QR code 登入以確認不是網路問題"
+ "如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據"
+ "若無法運作,請手動登入"
+ "連線不安全"
+ "已在其他裝置上取消登入。"
+ "已取消登入請求"
+ "其他裝置拒絕登入。"
+ "已拒絕登入"
+ "登入已過期。請再試一次。"
+ "未及時完成登入"
+ "您的其他裝置不支援使用 QR cpde 登入 %s。
+
+嘗試手動登入,或是使用其他裝置掃描 QR code。"
+ "不支援 QR code"
+ "您的帳號提供者不支援 %1$s。"
+ "不支援 %1$s"
+ "使用其他裝置上顯示的 QR code。"
+ "再試一次"
+ "錯誤的 QR code"
+ "您必須授予 %1$s 權限以使用裝置相機才能繼續。"
+ "允許相機權限以掃描 QR code"
+ "發生意外錯誤。請再試一次。"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-zh/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
new file mode 100644
index 0000000000..99359cc695
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "扫描二维码"
+ "使用此设备扫描二维码"
+ "准备进行扫描"
+ "账户提供方不支持 %1$s."
+ "不支持 %1$s."
+ "不支持二维码"
+ "登录被另一台设备取消"
+ "登录请求已取消"
+ "登录已过期. 请重试."
+ "登录未及时完成"
+ "选择 %1$s"
+ "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。"
+ "现在怎么办?"
+ "如果这是网络问题,请尝试使用二维码再次登录"
+ "如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi"
+ "如果不起作用,请手动登录"
+ "连接不安全"
+ "登录被另一台设备取消"
+ "登录请求已取消"
+ "其它设备未接受请求"
+ "登录被拒绝"
+ "登录已过期. 请重试."
+ "登录未及时完成"
+ "另一个设备不支持使用二维码登录 %s.
+
+尝试手动或使用另一个设备扫描二维码."
+ "不支持二维码"
+ "账户提供方不支持 %1$s."
+ "不支持 %1$s."
+ "使用其他设备上显示的二维码。"
+ "再试一次"
+ "二维码错误"
+ "您需要授予 %1$s 使用设备摄像头的权限才能继续。"
+ "允许摄像头权限以扫描 QR 码"
+ "发生了意外错误。请再试一次。"
+
diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..321b168751
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,57 @@
+
+
+ "Scan the QR code"
+ "Open %1$s on a laptop or desktop computer"
+ "Scan the QR code with this device"
+ "Ready to scan"
+ "Open %1$s on a desktop computer to get the QR code"
+ "The numbers don’t match"
+ "Enter 2-digit code"
+ "This will verify that the connection to your other device is secure."
+ "Enter the number shown on your other device"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Your account provider doesn’t support signing into a new device with a QR code."
+ "QR code not supported"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Open %1$s on the other device"
+ "Select %1$s"
+ "“Sign in with QR code”"
+ "Scan the QR code shown here with the other device"
+ "Open %1$s on the other device"
+ "Desktop computer"
+ "Loading QR code…"
+ "Mobile device"
+ "What type of device do you want to link?"
+ "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider."
+ "The numbers don’t match"
+ "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."
+ "What now?"
+ "Try signing in again with a QR code in case this was a network problem"
+ "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"
+ "If that doesn’t work, sign in manually"
+ "Connection not secure"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "The sign in was declined on the other device."
+ "Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Your other device does not support signing in to %s with a QR code.
+
+Try signing in manually, or scan the QR code with another device."
+ "QR code not supported"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Use the QR code shown on the other device."
+ "Try again"
+ "Wrong QR code"
+ "You need to give permission for %1$s to use your device’s camera in order to continue."
+ "Allow camera access to scan the QR code"
+ "An unexpected error occurred. Please try again."
+
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
new file mode 100644
index 0000000000..2957a89495
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.node.TestParentNode
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class DefaultLinkNewDeviceEntryPointTest {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun `test node creation`() = runTest {
+ val entryPoint = DefaultLinkNewDeviceEntryPoint()
+ val client = FakeMatrixClient()
+ val parentNode = TestParentNode.create { buildContext, plugins ->
+ LinkNewDeviceFlowNode(
+ buildContext = buildContext,
+ plugins = plugins,
+ sessionCoroutineScope = backgroundScope,
+ linkNewMobileHandler = LinkNewMobileHandler(client),
+ linkNewDesktopHandler = LinkNewDesktopHandler(client),
+ )
+ }
+ val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() = lambdaError()
+ }
+ val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback)
+ assertThat(result).isInstanceOf(LinkNewDeviceFlowNode::class.java)
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
new file mode 100644
index 0000000000..b6b9769b64
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DesktopNoticePresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ presenter.test {
+ awaitItem().run {
+ assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS")
+ assertThat(canContinue).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - Continue with camera permissions can continue`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().canContinue).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter()
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().cameraPermissionState.showDialog).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(),
+) = DesktopNoticePresenter(
+ permissionsPresenterFactory = permissionsPresenterFactory,
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
new file mode 100644
index 0000000000..ac0a129f49
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+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.linknewdevice.impl.R
+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 DesktopNoticeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `when can continue - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(canContinue = true),
+ onReadyToScanClick = callback,
+ )
+ }
+ }
+
+ @Test
+ fun `on submit button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aDesktopNoticeState(eventSink = eventRecorder),
+ )
+ rule.clickOn(R.string.screen_link_new_device_desktop_submit)
+ eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: DesktopNoticeState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ onReadyToScanClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = onBackClicked,
+ onReadyToScanClick = onReadyToScanClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
new file mode 100644
index 0000000000..8f44182dd4
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+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.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+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 ErrorViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on start over button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.clickOn(CommonStrings.action_start_over)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setErrorView(
+ onRetry: () -> Unit,
+ errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
+ ) {
+ setContent {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = onRetry,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
new file mode 100644
index 0000000000..fe2e11e95c
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeCheckCodeSender
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class EnterNumberPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ assertThat(initialState.sendingCode.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - enter numbers`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("12"))
+ val state2 = awaitItem()
+ assertThat(state2.number).isEqualTo("12")
+ // Non numeric characters are ignored
+ state2.eventSink(EnterNumberEvent.UpdateNumber("1a"))
+ val state3 = awaitItem()
+ assertThat(state3.number).isEqualTo("1")
+ }
+ }
+
+ @Test
+ fun `present - continue in wrong state generates an error`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ initialState.eventSink(EnterNumberEvent.Continue)
+ val state2 = awaitItem()
+ assertThat(state2.sendingCode.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - continue when number is not valid invokes the navigator`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { false }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ val navigateToWrongNumberErrorLambda = lambdaRecorder { }
+ val navigator = FakeEnterNumberNavigator(
+ navigateToWrongNumberErrorLambda = navigateToWrongNumberErrorLambda,
+ )
+ createPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isLoading()).isTrue()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ navigateToWrongNumberErrorLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid but sending fails`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.failure(AN_EXCEPTION) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isFailure()).isTrue()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid and sending is successful`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.success(Unit) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ expectNoEvents()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ private fun createPresenter(
+ navigator: EnterNumberNavigator = FakeEnterNumberNavigator(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
+ ) = EnterNumberPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
new file mode 100644
index 0000000000..e7466a1b21
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import org.junit.Test
+
+class EnterNumberStateTest {
+ @Test
+ fun `isContinueButtonEnabled is false if number is not complete`() {
+ val sut = aEnterNumberState(
+ number = "",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.copy(number = "1").isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is false if number is complete and sending is loading`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Loading,
+ )
+ assertThat(sut.isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete and sending is not loading`() {
+ listOf(
+ AsyncAction.Uninitialized,
+ AsyncAction.Failure(AN_EXCEPTION),
+ AsyncAction.Success(Unit),
+ ).forEach { action ->
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = action,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case empty`() {
+ val sut = aEnterNumberState(
+ number = "",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Empty,
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case half filled`() {
+ val sut = aEnterNumberState(
+ number = "1",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case filled`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Filled('2'),
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
new file mode 100644
index 0000000000..20e1d898dd
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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 EnterNumberViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `on continue button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aEnterNumberState(
+ number = "12",
+ eventSink = eventRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_continue)
+ eventRecorder.assertSingle(EnterNumberEvent.Continue)
+ }
+
+ @Test
+ fun `when the number is not complete, continue button is disabled`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ rule.setView(
+ state = aEnterNumberState(
+ number = "1",
+ eventSink = eventRecorder,
+ ),
+ )
+ val continueStr = rule.activity.getString(CommonStrings.action_continue)
+ rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: EnterNumberState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ EnterNumberView(
+ state = state,
+ onBackClick = onBackClicked,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
new file mode 100644
index 0000000000..a96ab7fe30
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeEnterNumberNavigator(
+ private val navigateToWrongNumberErrorLambda: () -> Unit = { lambdaError() },
+) : EnterNumberNavigator {
+ override fun navigateToWrongNumberError() {
+ navigateToWrongNumberErrorLambda()
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
new file mode 100644
index 0000000000..c6c89ba818
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+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.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.ensureCalledOnce
+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 ShowQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
new file mode 100644
index 0000000000..88a68786f3
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LinkNewDeviceRootPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - new login device not supported`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(false) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - error`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.failure(AN_EXCEPTION) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - link new mobile device`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.dataOrNull()).isTrue()
+ initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
+ val loadingState = awaitItem()
+ assertThat(loadingState.qrCodeData.isLoading()).isTrue()
+ }
+ }
+
+ private fun createPresenter(
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(matrixClient),
+ ) = LinkNewDeviceRootPresenter(
+ matrixClient = matrixClient,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
new file mode 100644
index 0000000000..e352debfb0
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.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.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncData
+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.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LinkNewDeviceRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `link desktop button clicked - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ ),
+ onLinkDesktopDeviceClick = callback,
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_desktop_computer)
+ }
+ }
+
+ @Test
+ fun `link mobile button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_mobile_device)
+ eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
+ }
+
+ @Test
+ fun `not supported - dismiss click - invokes the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(false),
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback,
+ )
+ rule.clickOn(CommonStrings.action_dismiss)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setLinkNewDeviceRootView(
+ state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = onBackClick,
+ onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
new file mode 100644
index 0000000000..50c3ce767b
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkDesktopHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class ScanQrCodePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ createLinkDesktopHandlerResult = { Result.success(FakeLinkDesktopHandler()) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - success`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(
+ FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ )
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ runCurrent()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - failure`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val handler = FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(handler)
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ handler.emitStep(LinkDesktopStep.InvalidQrCode(QrCodeDecodeException.Crypto("Invalid QR Code")))
+ skipItems(1)
+ val errorState = awaitItem()
+ assertThat(errorState.scanAction.isFailure()).isTrue()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ // Reset by trying again
+ errorState.eventSink(ScanQrCodeEvent.TryAgain)
+ val resetState = awaitItem()
+ assertThat(resetState.scanAction.isLoading()).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ matrixClient: MatrixClient,
+) = ScanQrCodePresenter(
+ linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient),
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
new file mode 100644
index 0000000000..fcc3afeb7d
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+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.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+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.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScanQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aScanQrCodeState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `try again button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aScanQrCodeState(
+ scanAction = AsyncAction.Failure(AN_EXCEPTION),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(CommonStrings.action_try_again)
+ eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: ScanQrCodeState = aScanQrCodeState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts
new file mode 100644
index 0000000000..388612f920
--- /dev/null
+++ b/features/linknewdevice/test/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.test"
+}
+
+dependencies {
+ implementation(projects.features.linknewdevice.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
index 1ebf00dd10..091432044a 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
@@ -71,7 +71,7 @@ class DefaultPinCodeManager(
lockScreenStore.onWrongPin()
}
}
- } catch (failure: Throwable) {
+ } catch (_: Throwable) {
false
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
index 2131853e3e..093ad2f2b4 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -21,8 +21,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -40,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
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.text.toSp
@@ -206,7 +205,8 @@ private fun PinKeypadBackButton(
onClick = onClick,
) {
Icon(
- imageVector = Icons.AutoMirrored.Filled.Backspace,
+ modifier = Modifier.size(28.dp),
+ imageVector = CompoundIcons.BackspaceSolid(),
contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
diff --git a/features/lockscreen/impl/src/main/res/values-hr/translations.xml b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..1a81bcc6cb
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "biometrijska provjera autentičnosti"
+ "biometrijsko otključavanje"
+ "Otključavanje biometrijom"
+ "Potvrdi biometriju"
+ "Zaboravili ste PIN?"
+ "Promijeni PIN kod"
+ "Omogući biometrijsko otključavanje"
+ "Ukloni PIN"
+ "Jeste li sigurni da želite ukloniti PIN?"
+ "Želite li ukloniti PIN?"
+ "Dopusti %1$s"
+ "Radije bih upotrijebio/la PIN"
+ "Uštedite si malo vremena i iskoristite %1$s kako biste svaki put otključali aplikaciju"
+ "Odaberite PIN"
+ "Potvrdite PIN"
+ "Zaključajte %1$s kako biste dodatno osigurali svoje razgovore.
+
+Odaberite nešto nezaboravno. Ako zaboravite ovaj PIN, bit ćete odjavljeni iz aplikacije."
+ "Ovo ne možete iz sigurnosnih razloga odabrati kao svoj PIN kod"
+ "Odaberite drugi PIN"
+ "Unesite dvaput isti PIN"
+ "PIN-ovi se ne podudaraju"
+ "Morat ćete se ponovno prijaviti i izraditi novi PIN da biste mogli nastaviti"
+ "Odjavit ćete se"
+
+ - "Imate %1$d pokušaj otključavanja"
+ - "Imate %1$d pokušaja otključavanja"
+ - "Imate %1$d pokušaja otključavanja"
+
+
+ - "Pogrešan PIN. Imate još %1$d pokušaj"
+ - "Pogrešan PIN. Imate još %1$d pokušaja"
+ - "Pogrešan PIN. Imate još %1$d pokušaja"
+
+ "Upotrijebi biometriju"
+ "Upotrijebi PIN"
+ "Odjavljivanje…"
+
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
index f7a23df7d1..bf7af111f4 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
@@ -19,24 +19,18 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
-import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
-import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CreateAccountPresenter(
@Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService,
- private val clientProvider: MatrixClientProvider,
private val messageParser: MessageParser,
private val buildMeta: BuildMeta,
) : Presenter {
@@ -80,12 +74,6 @@ class CreateAccountPresenter(
}.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession)
}.onSuccess { sessionId ->
- tryOrNull {
- // Wait until the session is verified
- val client = clientProvider.getOrRestore(sessionId).getOrThrow()
- val sessionVerificationService = client.sessionVerificationService
- withTimeout(10.seconds) { sessionVerificationService.sessionVerifiedStatus.first { it.isVerified() } }
- }
loggedInState.value = AsyncAction.Success(sessionId)
}.onFailure { failure ->
loggedInState.value = AsyncAction.Failure(failure)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
index 4da6448027..13888fe23f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
@@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
@Inject
@@ -46,7 +46,7 @@ class QrCodeIntroPresenter(
canContinue = true
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
index 4f444b14bc..6fa3d1b0c7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
@@ -106,7 +106,7 @@ private fun Content(
QrCodeCameraView(
modifier = Modifier.fillMaxSize(),
onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) },
- renderPreview = state.isScanning,
+ isScanning = state.isScanning,
)
}
}
diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml
index 6ffd9a84d4..73f0dd51cc 100644
--- a/features/login/impl/src/main/res/values-cs/translations.xml
+++ b/features/login/impl/src/main/res/values-cs/translations.xml
@@ -60,6 +60,8 @@
"Žádost o přihlášení zrušena"
"Přihlášení bylo na druhém zařízení odmítnuto."
"Přihlášení odmítnuto"
+ "Nemusíte dělat nic jiného."
+ "Vaše další zařízení je již přihlášeno"
"Platnost přihlášení vypršela. Zkuste to prosím znovu."
"Přihlášení nebylo dokončeno včas"
"Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu.
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 cb3459f62b..f1d426e134 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -60,6 +60,8 @@
"Anmeldeanfrage abgebrochen"
"Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
"Anmelden abgelehnt"
+ "Du musst nichts weiter tun."
+ "Dein anderes Gerät ist schon angemeldet."
"Die Anmeldung ist abgelaufen. Bitte versuche es erneut."
"Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
"Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht.
diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml
index 3e3e6add37..0a1a99383d 100644
--- a/features/login/impl/src/main/res/values-et/translations.xml
+++ b/features/login/impl/src/main/res/values-et/translations.xml
@@ -60,6 +60,8 @@
"Sisselogimispäring on tühistatud"
"Sisselogimisest on teises seadmes keeldutud."
"Sisselogimisest on keeldutud"
+ "Sa ei pea enam midagi muud tegema."
+ "Sinu muu seade on juba sisse logitud"
"Sisselogimine aegus. Palun proovi uuesti."
"Sisselogimine jäi etteantud aja jooksul tegemata"
"Sinu teine seade ei toeta %s sisselogimist QR-koodiga.
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 946fec802e..9846feec38 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -40,7 +40,7 @@
"Version %1$s"
"Se connecter manuellement"
"Connectez-vous à %1$s"
- "Se connecter avec un QR code"
+ "Se connecter avec un code QR"
"Créer un compte"
"Bienvenue dans l’application %1$s la plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité."
"Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité."
@@ -60,6 +60,8 @@
"Demande de connexion annulée"
"La connexion a été refusée sur l’autre appareil."
"Connexion refusée"
+ "Vous n’avez rien d’autre à faire."
+ "Votre autre appareil est déjà connecté"
"Connexion expirée. Veuillez essayer à nouveau."
"La connexion a pris trop de temps."
"Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil."
@@ -73,14 +75,14 @@
"“Associer une nouvelle session”"
"Scanner le code QR avec cet appareil"
"Disponible uniquement si votre fournisseur de compte le supporte."
- "Ouvrez %1$s sur un autre appareil pour obtenir le QR code"
- "Scannez le QR code affiché sur l’autre appareil."
+ "Ouvrez %1$s sur un autre appareil pour obtenir le code QR"
+ "Scannez le code QR affiché sur l’autre appareil."
"Essayer à nouveau"
- "QR code erroné"
+ "Code QR erroné"
"Accéder aux paramètres de l’appareil photo"
"Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."
"Autoriser l’usage de la caméra pour scanner le code QR"
- "Scannez le QR code"
+ "Scannez le code QR"
"Recommencer"
"Une erreur inattendue s’est produite. Veuillez réessayer."
"En attente de votre autre session"
diff --git a/features/login/impl/src/main/res/values-hr/translations.xml b/features/login/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..ced196f87e
--- /dev/null
+++ b/features/login/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,100 @@
+
+
+ "Promijeni davatelja usluga računa"
+ "Adresa matičnog poslužitelja"
+ "Unesite pojam za pretraživanje ili adresu domene."
+ "Potražite tvrtku, zajednicu ili privatni poslužitelj."
+ "Pronađite davatelja usluga računa"
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Prijavit ćete se na %s"
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Izradit ćete račun na %s"
+ "Matrix.org velik je, besplatni poslužitelj na javnoj Matrixovoj mreži koji pruža sigurnu, decentraliziranu komunikaciju, a kojim upravlja zaklada Matrix.org."
+ "Ostalo"
+ "Koristite drugog davatelja računa, kao što je vlastiti privatni poslužitelj ili poslovni račun."
+ "Promijeni davatelja usluga računa"
+ "Google Play"
+ "Potrebna je aplikacija Element Pro na %1$s. Molimo vas da je preuzmete iz trgovine."
+ "Potreban je Element Pro"
+ "Nismo mogli pristupiti ovom matičnom poslužitelju. Provjerite jeste li ispravno unijeli URL matičnog poslužitelja. Ako je URL ispravan, obratite se administratoru matičnog poslužitelja za daljnju pomoć."
+ "Poslužitelj nije dostupan zbog problema u .well-known datoteci:
+%1$s"
+ "Odabrani davatelj usluga računa ne podržava sliding sync. Za korištenje je potrebna nadogradnja poslužitelja %1$ssliding sync."
+ "%1$s nije dopušteno povezivanje s %2$s."
+ "Ova je aplikacija konfigurirana tako da dopušta: %1$s."
+ "Davatelj usluga računa %1$s nije dopušten."
+ "URL matičnog poslužitelja"
+ "Unesite adresu domene."
+ "Koja je adresa vašeg poslužitelja?"
+ "Odaberite svoj poslužitelj"
+ "Izradi račun"
+ "Ovaj je račun deaktiviran."
+ "Netočno korisničko ime i/ili zaporka"
+ "To nije valjani identifikator korisnika. Očekivani oblik: ‘@korisnik:matičniposlužitelj.org’"
+ "Ovaj je poslužitelj konfiguriran za korištenje tokena za osvježavanje. Oni nisu podržani kada se upotrebljava prijava temeljena na zaporki."
+ "Odabrani matični poslužitelj ne podržava zaporku ili OIDC prijavu. Obratite se administratoru ili odaberite drugi matični poslužitelj."
+ "Unesite svoje podatke"
+ "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju."
+ "Dobro došli natrag!"
+ "Prijavi se na poslužitelj %1$s"
+ "Inačica %1$s"
+ "Prijavi se ručno"
+ "Prijavi se na poslužitelj %1$s"
+ "Prijavi se pomoću QR koda"
+ "Izradi račun"
+ "Dobro došli u nikad brži %1$s. Snažniji no ikad za postizanje brzine i jednostavnosti."
+ "Dobro došli u %1$s. Snažniji no ikad – za brzinu i jednostavnost."
+ "Budi u elementu"
+ "Uspostavljanje sigurne veze"
+ "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih."
+ "Što sad?"
+ "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom"
+ "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja."
+ "Ako to ne uspije, prijavite se ručno"
+ "Veza nije sigurna"
+ "Od vas će se zatražiti da unesete dvije znamenke prikazane na ovom uređaju."
+ "Unesite ispod navedeni broj u svoj drugi uređaj"
+ "Prijavite se na drugi uređaj i pokušajte ponovno ili upotrijebite drugi uređaj na kojem ste već prijavljeni."
+ "Niste prijavljeni na drugom uređaju"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je odbijena na drugom uređaju."
+ "Prijava je odbijena"
+ "Ne morate ništa drugo napraviti."
+ "Vaš drugi uređaj već je prijavljen"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda.
+
+Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem."
+ "QR kod nije podržan"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Spremno za skeniranje"
+ "Otvorite %1$s na stolnom uređaju"
+ "Kliknite na svoj avatar"
+ "Odaberite %1$s"
+ "“Poveži novi uređaj”"
+ "Skenirajte QR kod ovim uređajem"
+ "Dostupno samo ako vaš davatelj usluge računa to podržava."
+ "Otvorite %1$s na drugom uređaju kako biste dobili QR kod"
+ "Upotrijebite QR kod prikazan na drugom uređaju."
+ "Pokušajte ponovno"
+ "Pogrešan QR kod"
+ "Idi na postavke kamere"
+ "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja."
+ "Dopustite pristup kameri kako biste mogli skenirati QR kod"
+ "Skeniraj QR kod"
+ "Kreni ispočetka"
+ "Došlo je do neočekivane pogreške. Pokušajte ponovno."
+ "Čekanje na vaš drugi uređaj"
+ "Davatelj usluge računa može zatražiti sljedeći kod za potvrdu prijave."
+ "Vaš verifikacijski kod"
+ "Promijeni davatelja usluga računa"
+ "Privatni poslužitelj za zaposlenike aplikacije Element."
+ "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju."
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Prijavit ćete se na poslužitelj %1$s"
+ "Odaberite davatelja usluga računa"
+ "Izradit ćete račun na poslužitelju %1$s"
+
diff --git a/features/login/impl/src/main/res/values-pt-rBR/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
index ce67264ae8..afca14d201 100644
--- a/features/login/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
@@ -60,6 +60,8 @@
"Solicitação de entrada foi cancelada"
"A entrada foi recusada no outro dispositivo."
"Entrada recusada"
+ "Você não precisa fazer mais nada."
+ "O seu outro dispositivo já está conectado"
"O processo de entrada expirou. Tente novamente."
"A entrada não foi concluída a tempo"
"Seu outro dispositivo não tem suporte a entrar no %s com um código QR.
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 9b235558c8..832c3b7f71 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -60,6 +60,8 @@
"Sign in request cancelled"
"The sign in was declined on the other device."
"Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
"Sign in expired. Please try again."
"The sign in was not completed in time"
"Your other device does not support signing in to %s with a QR code.
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
index a9d3c02368..c70a5c9a9a 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
@@ -16,8 +16,6 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -80,14 +78,11 @@ class CreateAccountPresenterTest {
fun `present - receiving a message able to be parsed change the state to success`() = runTest {
val lambda = lambdaRecorder { _ -> anExternalSession() }
val sessionVerificationService = FakeSessionVerificationService()
- val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService)
- val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
),
messageParser = FakeMessageParser(lambda),
- clientProvider = clientProvider,
)
presenter.test {
val initialState = awaitItem()
@@ -120,12 +115,10 @@ class CreateAccountPresenterTest {
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
messageParser: MessageParser = FakeMessageParser(),
buildMeta: BuildMeta = aBuildMeta(),
- clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
) = CreateAccountPresenter(
url = url,
authenticationService = authenticationService,
messageParser = messageParser,
buildMeta = buildMeta,
- clientProvider = clientProvider,
)
}
diff --git a/features/logout/impl/src/main/res/values-hr/translations.xml b/features/logout/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..331eca04c4
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Jeste li sigurni da se želite odjaviti?"
+ "Odjava"
+ "Odjava"
+ "Odjavljivanje…"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama."
+ "Isključili ste sigurnosno kopiranje"
+ "Vaši su se ključevi još uvijek sigurnosno kopirali kada ste se isključili iz mreže. Ponovno se povežite kako bi se vaši ključevi mogli sigurnosno kopirati prije nego što se odjavite."
+ "Vaši se ključevi još uvijek sigurnosno kopiraju"
+ "Pričekajte da se to dovrši prije nego što se odjavite."
+ "Vaši se ključevi još uvijek sigurnosno kopiraju"
+ "Odjava"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama."
+ "Oporavak nije postavljen"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, možda nećete moći pristupiti svojim šifriranim porukama."
+ "Jeste li spremili svoj ključ za oporavak?"
+
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index ad6562a83c..eb8aff66ed 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
+ implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
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 e912722c6d..8a5f3cf423 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
@@ -12,12 +12,10 @@ 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
@@ -32,6 +30,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
@@ -75,14 +74,10 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
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.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -107,6 +102,7 @@ class MessagesPresenter(
@Assisted private val timelinePresenter: Presenter,
private val timelineProtectionPresenter: Presenter,
private val identityChangeStatePresenter: Presenter,
+ private val historyVisibleStatePresenter: Presenter,
private val linkPresenter: Presenter,
@Assisted private val actionListPresenter: Presenter,
private val customReactionPresenter: Presenter,
@@ -158,6 +154,7 @@ class MessagesPresenter(
val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
+ val historyVisibleState = historyVisibleStatePresenter.present()
val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present()
@@ -167,7 +164,9 @@ class MessagesPresenter(
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
- val userEventPermissions by userEventPermissions(roomInfo)
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
@@ -278,6 +277,7 @@ class MessagesPresenter(
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
+ historyVisibleState = historyVisibleState,
linkState = linkState,
actionListState = actionListState,
customReactionState = customReactionState,
@@ -297,24 +297,6 @@ class MessagesPresenter(
)
}
- @Composable
- private fun userEventPermissions(roomInfo: RoomInfo): State {
- val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
- Long.MAX_VALUE
- } else {
- roomInfo.roomPowerLevels?.hashCode() ?: 0L
- }
- return produceState(UserEventPermissions.DEFAULT, key1 = key) {
- value = UserEventPermissions(
- canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).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 RoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,
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 9faf2f69eb..b9d86a6597 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
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@@ -40,6 +41,7 @@ data class MessagesState(
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
+ val historyVisibleState: HistoryVisibleState,
val linkState: LinkState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
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 3a077e6cf0..cec9f68b45 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
@@ -14,7 +14,10 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
+import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
+import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
@@ -38,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
+import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -48,6 +52,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -83,6 +88,19 @@ open class MessagesStateProvider : PreviewParameterProvider {
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
)
),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
+ ),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ historyVisibleState = aHistoryVisibleState(showAlert = true)
+ ),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())),
+ historyVisibleState = aHistoryVisibleState(showAlert = true)
+ )
)
}
@@ -103,6 +121,7 @@ fun aMessagesState(
),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(),
+ historyVisibleState: HistoryVisibleState = aHistoryVisibleState(),
linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
@@ -125,6 +144,7 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
+ historyVisibleState = historyVisibleState,
linkState = linkState,
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
@@ -145,11 +165,9 @@ fun aMessagesState(
)
fun aRoomMemberModerationState(
- canKick: Boolean = false,
- canBan: Boolean = false,
+ permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
) = object : RoomMemberModerationState {
- override val canKick: Boolean = canKick
- override val canBan: Boolean = canBan
+ override val permissions: RoomMemberModerationPermissions = permissions
override val eventSink: (RoomMemberModerationEvents) -> 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 03b04608d6..ea50cf0fe2 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
@@ -29,10 +29,15 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.shadow
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -41,6 +46,7 @@ import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -48,6 +54,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
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.crypto.historyvisible.HistoryVisibleStateView
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
@@ -62,6 +69,10 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
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.aGroupedEvents
+import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@@ -69,6 +80,9 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
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.TimelineItemGroupPosition
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@@ -82,6 +96,7 @@ import io.element.android.libraries.designsystem.components.rememberExpandableBo
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@@ -96,10 +111,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
+import kotlinx.collections.immutable.persistentListOf
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@@ -129,6 +146,8 @@ fun MessagesView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
+ var maxComposerHeightPx by remember { mutableIntStateOf(120) }
+
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
@@ -179,7 +198,13 @@ fun MessagesView(
modifier = modifier
.fillMaxSize()
.imePadding()
- .systemBarsPadding(),
+ .systemBarsPadding()
+ .onSizeChanged { size ->
+ // Let the composer takes at max half of the available height.
+ // The value will be different if the soft keyboard is displayed
+ // or not.
+ maxComposerHeightPx = (size.height * 0.5f).toInt()
+ },
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@@ -313,7 +338,7 @@ fun MessagesView(
} else {
RectangleShape
},
- maxBottomSheetContentHeight = 360.dp,
+ maxBottomSheetContentHeight = maxComposerHeightPx.toDp(),
)
ActionListView(
@@ -472,10 +497,17 @@ private fun MessagesViewComposerBottomSheetContents(
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
- IdentityChangeStateView(
- state = state.identityChangeState,
- onLinkClick = onLinkClick,
- )
+ if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) {
+ IdentityChangeStateView(
+ state = state.identityChangeState,
+ onLinkClick = onLinkClick,
+ )
+ } else {
+ HistoryVisibleStateView(
+ state = state.historyVisibleState,
+ onLinkClick = onLinkClick,
+ )
+ }
}
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
@@ -550,3 +582,57 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
knockRequestsBannerView = {},
)
}
+
+@Preview
+@Composable
+internal fun MessagesViewA11yPreview() = ElementPreview {
+ val content = aTimelineItemTextContent(
+ body = "A message content"
+ )
+ MessagesView(
+ state = aMessagesState(
+ roomName = "A DM with a very looong name",
+ dmUserVerificationState = IdentityState.VerificationViolation,
+ timelineState = aTimelineState(
+ timelineItems = persistentListOf(
+ // 1 items with isMine = false
+ aTimelineItemEvent(
+ isMine = false,
+ content = content,
+ groupPosition = TimelineItemGroupPosition.None,
+ sendState = LocalEventSendState.Failed.Unknown("Message failed to send"),
+ ),
+ // A state event on top of it
+ aTimelineItemEvent(
+ isMine = false,
+ content = aTimelineItemStateEventContent(),
+ groupPosition = TimelineItemGroupPosition.None
+ ),
+ // 1 item with isMine = true
+ aTimelineItemEvent(
+ isMine = true,
+ content = content,
+ groupPosition = TimelineItemGroupPosition.None
+ ),
+ // A grouped event on top of it
+ aGroupedEvents(),
+ // A day separator
+ aTimelineItemDaySeparator(),
+ ),
+ // Render a focused event for an event with sender information displayed
+ focusedEventIndex = 2,
+ )
+ ),
+ onBackClick = {},
+ onRoomDetailsClick = {},
+ onEventContentClick = { _, _ -> false },
+ onUserDataClick = {},
+ onLinkClick = { _, _ -> },
+ onSendLocationClick = {},
+ onCreatePollClick = {},
+ onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = { },
+ forceJumpToBottomVisibility = true,
+ knockRequestsBannerView = {},
+ )
+}
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
index f7d221950b..349c8e58dc 100644
--- 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
@@ -8,6 +8,9 @@
package io.element.android.features.messages.impl
+import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
/**
* Represents the permissions a user has in a room.
* It's dependent of the user's power level in the room.
@@ -29,3 +32,13 @@ data class UserEventPermissions(
)
}
}
+
+fun RoomPermissions.userEventPermissions(): UserEventPermissions {
+ return UserEventPermissions(
+ canRedactOwn = canOwnUserRedactOwn(),
+ canRedactOther = canOwnUserRedactOther(),
+ canSendMessage = canOwnUserSendMessage(MessageEventType.RoomMessage),
+ canSendReaction = canOwnUserSendMessage(MessageEventType.Reaction),
+ canPinUnpin = canOwnUserPinUnpin()
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index d7e0332bd4..f7641ec21b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
+import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@@ -62,6 +63,7 @@ class AttachmentsPreviewPresenter(
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
+ private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -107,13 +109,9 @@ class AttachmentsPreviewPresenter(
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
- val mediaOptimizationConfig = MediaOptimizationConfig(
- compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
- videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
- )
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
- mediaOptimizationConfig = mediaOptimizationConfig,
+ mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
displayProgress = false,
sendActionState = sendActionState,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
index 6823aead3f..70d7ab006e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
@@ -95,7 +95,7 @@ fun aMediaUploadInfo(
)
fun aMediaOptimisationSelectorState(
- maxUploadSize: Long = 100,
+ maxUploadSize: Long = 100 * 1024 * 1024,
videoSizeEstimations: AsyncData> = AsyncData.Success(persistentListOf()),
isImageOptimizationEnabled: Boolean = true,
selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
index 7c9ffdaf89..8f55957e32 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
@@ -231,7 +231,7 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
- style = ElementTheme.materialTypography.bodyLarge,
+ style = ElementTheme.typography.fontBodyLgRegular,
)
Switch(
modifier = Modifier.height(32.dp),
@@ -337,7 +337,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = preset.subtitle(),
- style = ElementTheme.materialTypography.bodyMedium,
+ style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
index d0716abe83..c81c306f90 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
@@ -25,13 +25,12 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
+import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.compressorHelper
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
-import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.flow.first
import timber.log.Timber
import kotlin.math.roundToLong
@@ -39,8 +38,8 @@ import kotlin.math.roundToLong
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val localMedia: LocalMedia,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
- private val sessionPreferencesStore: SessionPreferencesStore,
private val featureFlagService: FeatureFlagService,
+ private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@@ -124,11 +123,12 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
- selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first())
+ val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
+ selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
- defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
+ defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt
new file mode 100644
index 0000000000..1fa992fc3e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+interface HistoryVisibleAcknowledgementRepository {
+ fun hasAcknowledged(roomId: RoomId): Flow
+ suspend fun setAcknowledged(roomId: RoomId, value: Boolean)
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultHistoryVisibleAcknowledgementRepository(
+ sessionId: SessionId,
+ preferenceDataStoreFactory: PreferenceDataStoreFactory,
+) : HistoryVisibleAcknowledgementRepository {
+ val store =
+ sessionId.value.hash().take(16).let { hash ->
+ preferenceDataStoreFactory.create("elementx_historyvisible_$hash")
+ }
+
+ override fun hasAcknowledged(roomId: RoomId): Flow {
+ return store.data.map { prefs ->
+ val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false
+ acknowledged
+ }
+ }
+
+ override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
+ store.edit { prefs ->
+ prefs[booleanPreferencesKey(roomId.value)] = value
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt
new file mode 100644
index 0000000000..775d9c00d4
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+sealed interface HistoryVisibleEvent {
+ data object Acknowledge : HistoryVisibleEvent
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt
new file mode 100644
index 0000000000..3f980eb086
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+data class HistoryVisibleState(
+ val showAlert: Boolean,
+ val eventSink: (HistoryVisibleEvent) -> Unit,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt
new file mode 100644
index 0000000000..e79681cd5c
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import dev.zacsweers.metro.Inject
+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.RoomId
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Inject
+class HistoryVisibleStatePresenter(
+ private val featureFlagService: FeatureFlagService,
+ private val repository: HistoryVisibleAcknowledgementRepository,
+ private val room: JoinedRoom,
+) : Presenter {
+ @Composable
+ override fun present(): HistoryVisibleState {
+ val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
+ val roomInfo by room.roomInfoFlow.collectAsState()
+ // Implicitly assume the alert is initially acknowledged to avoid flashes in UI.
+ val acknowledged by repository.hasAcknowledged(room.roomId).collectAsState(initial = true)
+ val isHistoryVisible = roomInfo.historyVisibility == RoomHistoryVisibility.Shared || roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable
+
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(isHistoryVisible, acknowledged) {
+ if (!isHistoryVisible && acknowledged) {
+ // Clear the dismissed flag, if it is set to ensure that if a room is changed public -> private -> public,
+ // we show the banner again when it is set back to public.
+ repository.setAcknowledged(room.roomId, false)
+ }
+ }
+
+ fun handleEvent(event: HistoryVisibleEvent) {
+ when (event) {
+ is HistoryVisibleEvent.Acknowledge -> coroutineScope.setAcknowledged(room.roomId, true)
+ }
+ }
+
+ return HistoryVisibleState(
+ showAlert = isFeatureEnabled && isHistoryVisible && roomInfo.isEncrypted == true && !acknowledged,
+ eventSink = ::handleEvent,
+ )
+ }
+
+ private fun CoroutineScope.setAcknowledged(roomId: RoomId, value: Boolean) = launch {
+ repository.setAcknowledged(roomId, value)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt
new file mode 100644
index 0000000000..752abdc76b
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class HistoryVisibleStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aHistoryVisibleState(showAlert = true),
+ )
+}
+
+internal fun aHistoryVisibleState(
+ showAlert: Boolean = false,
+ eventSink: (HistoryVisibleEvent) -> Unit = {},
+) = HistoryVisibleState(
+ showAlert,
+ eventSink = eventSink,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt
new file mode 100644
index 0000000000..d0655f695d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.appconfig.LearnMoreConfig
+import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel
+import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.stringWithLink
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun HistoryVisibleStateView(
+ state: HistoryVisibleState,
+ onLinkClick: (String, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (!state.showAlert) {
+ return
+ }
+ ComposerAlertMolecule(
+ modifier = modifier,
+ avatar = null,
+ showIcon = true,
+ level = ComposerAlertLevel.Info,
+ content = stringWithLink(
+ textRes = CommonStrings.crypto_history_visible,
+ url = LearnMoreConfig.HISTORY_VISIBLE_URL,
+ onLinkClick = { url -> onLinkClick(url, true) },
+ ),
+ submitText = stringResource(CommonStrings.action_dismiss),
+ onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) },
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun HistoryVisibleStateViewPreview(
+ @PreviewParameter(HistoryVisibleStateProvider::class) state: HistoryVisibleState,
+) = ElementPreview {
+ HistoryVisibleStateView(
+ state = state,
+ onLinkClick = { _, _ -> },
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt
new file mode 100644
index 0000000000..07cf5170d3
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.messages.impl.MessagesView
+import io.element.android.features.messages.impl.aMessagesState
+import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
+
+@PreviewsDayNight
+@Composable
+internal fun MessagesViewWithHistoryVisiblePreview() = ElementPreview {
+ MessagesView(
+ state = aMessagesState(
+ composerState = aMessageComposerState(
+ textEditorState = aTextEditorStateMarkdown(
+ initialText = "",
+ initialFocus = false,
+ )
+ ),
+ historyVisibleState = aHistoryVisibleState(showAlert = true),
+ ),
+ onBackClick = {},
+ onRoomDetailsClick = {},
+ onEventContentClick = { _, _ -> false },
+ onUserDataClick = {},
+ onLinkClick = { _, _ -> },
+ onSendLocationClick = {},
+ onCreatePollClick = {},
+ onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = {},
+ knockRequestsBannerView = {}
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
index a345e09fa2..a88dbb1b49 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
@@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStatePresenter
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
@@ -61,4 +63,7 @@ interface MessagesBindsModule {
@Binds
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter
+
+ @Binds
+ fun bindHistoryVisibleStatePresenter(presenter: HistoryVisibleStatePresenter): Presenter
}
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 276499d5bb..fd803109c7 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
@@ -55,6 +55,7 @@ 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.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -63,7 +64,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -98,6 +99,7 @@ import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
+@Suppress("LargeClass")
@AssistedInject
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@@ -284,7 +286,7 @@ class MessageComposerPresenter(
cameraPhotoPicker.launch()
} else {
pendingEvent = event
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
@@ -293,7 +295,7 @@ class MessageComposerPresenter(
cameraVideoPicker.launch()
} else {
pendingEvent = event
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
MessageComposerEvent.PickAttachmentSource.Location -> {
@@ -396,7 +398,9 @@ class MessageComposerPresenter(
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
- val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
+ val userCanSendAtRoom = room.roomPermissions().use(false) { perms ->
+ perms.canOwnUserTriggerRoomNotification()
+ }
return !room.isDm() && userCanSendAtRoom
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
index 57af770d5b..292a77ba6a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -100,6 +101,7 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
+ val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
val state = presenter.present()
PinnedMessagesListView(
@@ -113,8 +115,8 @@ class PinnedMessagesListNode(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
- it.url,
- context.getString(CommonStrings.common_copied_to_clipboard)
+ text = it.url,
+ toastMessage = toastMessage,
)
},
modifier = modifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
index cdc1f85f1d..b2d2caa7f9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
@@ -10,11 +10,10 @@ package io.element.android.features.messages.impl.pinned.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-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.rememberUpdatedState
import androidx.compose.runtime.setValue
@@ -35,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
+import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -44,11 +44,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
-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.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
-import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -97,31 +95,33 @@ class PinnedMessagesListPresenter(
@Composable
override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update()
- val isDm by room.isDmAsState()
-
- val timelineRoomInfo = remember(isDm) {
- TimelineRoomInfo(
- isDm = isDm,
- name = room.info().name,
- // We don't need to compute those values
- userHasPermissionToSendMessage = false,
- userHasPermissionToSendReaction = false,
- // We do not care about the call state here.
- roomCallState = aStandByCallState(),
- // don't compute this value or the pin icon will be shown
- pinnedEventIds = persistentListOf(),
- typingNotificationState = TypingNotificationState(
- renderTypingNotifications = false,
- typingMembers = persistentListOf(),
- reserveSpace = false,
- ),
- predecessorRoom = room.predecessorRoom(),
- )
+ val roomInfo by room.roomInfoFlow.collectAsState()
+ val timelineRoomInfo by remember {
+ derivedStateOf {
+ TimelineRoomInfo(
+ isDm = roomInfo.isDm,
+ name = roomInfo.name,
+ // We don't need to compute those values
+ userHasPermissionToSendMessage = false,
+ userHasPermissionToSendReaction = false,
+ // We do not care about the call state here.
+ roomCallState = aStandByCallState(),
+ // don't compute this value or the pin icon will be shown
+ pinnedEventIds = persistentListOf(),
+ typingNotificationState = TypingNotificationState(
+ renderTypingNotifications = false,
+ typingMembers = persistentListOf(),
+ reserveSpace = false,
+ ),
+ predecessorRoom = room.predecessorRoom(),
+ )
+ }
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
- val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
@@ -192,19 +192,6 @@ class PinnedMessagesListPresenter(
}
}
- @Composable
- private fun userEventPermissions(updateKey: Long): State {
- return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
- value = UserEventPermissions(
- canSendMessage = false,
- canSendReaction = false,
- canRedactOwn = room.canRedactOwn().getOrElse { false },
- canRedactOther = room.canRedactOther().getOrElse { false },
- canPinUnpin = room.canPinUnpin().getOrElse { false },
- )
- }
- }
-
@Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
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 e44cac4f27..fd7d200a52 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
@@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
+import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -32,12 +33,12 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
+import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -46,20 +47,20 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
-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.permissionsAsState
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.Timeline
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 io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
-import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
+import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
@@ -95,6 +96,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService,
) : Presenter {
private val tag = "TimelinePresenter"
+
@AssistedFactory
interface Factory {
fun create(
@@ -128,11 +130,6 @@ class TimelinePresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
-
- val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value)
- val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value)
-
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
@@ -208,7 +205,7 @@ class TimelinePresenter(
}.start()
is TimelineEvents.OnFocusEventRender -> {
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event
- analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline)
+ analyticsService.finishLongRunningTransaction(NotificationToMessage)
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}
@@ -253,7 +250,7 @@ class TimelinePresenter(
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems)
val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items")
- transaction?.setData("items", items.count())
+ transaction?.putExtraData(AnalyticsUserData.TIMELINE_ITEM_COUNT, items.count().toString())
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
@@ -285,13 +282,16 @@ class TimelinePresenter(
val typingNotificationState = typingNotificationPresenter.present()
val roomCallState = roomCallStatePresenter.present()
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf {
TimelineRoomInfo(
name = roomInfo.name,
- isDm = roomInfo.isDm.orFalse(),
- userHasPermissionToSendMessage = userHasPermissionToSendMessage,
- userHasPermissionToSendReaction = userHasPermissionToSendReaction,
+ isDm = roomInfo.isDm,
+ userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
+ userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
roomCallState = roomCallState,
pinnedEventIds = roomInfo.pinnedEventIds,
typingNotificationState = typingNotificationState,
@@ -394,9 +394,8 @@ class TimelinePresenter(
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewEvent) {
- val newMostRecentEvent = newMostRecentItem
// Scroll to bottom if the new event is from me, even if sent from another device
- val fromMe = newMostRecentEvent?.isMine == true
+ val fromMe = newMostRecentItem.isMine
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
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 b3f69f0e2b..73a15b797f 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
@@ -123,6 +123,7 @@ fun TimelineView(
}
val context = LocalContext.current
+ val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = !isTalkbackActive()
@@ -136,8 +137,8 @@ fun TimelineView(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
- link.url,
- context.getString(CommonStrings.common_copied_to_clipboard)
+ text = link.url,
+ toastMessage = toastMessage,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
index a3f214f979..5dbd0c478f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -42,6 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
+import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
-import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
+import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@@ -64,26 +66,26 @@ fun TimelineItemVoiceView(
modifier: Modifier = Modifier,
) {
fun playPause() {
- state.eventSink(VoiceMessageEvents.PlayPause)
+ state.eventSink(VoiceMessageEvent.PlayPause)
}
val a11y = stringResource(CommonStrings.common_voice_message)
val a11yActionLabel = stringResource(
- when (state.button) {
- VoiceMessageState.Button.Play -> CommonStrings.a11y_play
- VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
- VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
- VoiceMessageState.Button.Retry -> CommonStrings.action_retry
- VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
+ when (state.buttonType) {
+ VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play
+ VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause
+ VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading
+ VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry
+ VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown
}
)
Row(
modifier = modifier
.clearAndSetSemantics {
contentDescription = a11y
- if (state.button == VoiceMessageState.Button.Disabled) {
+ if (state.buttonType == VoiceMessageState.ButtonType.Disabled) {
disabled()
- } else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
+ } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) {
onClick(label = a11yActionLabel) {
playPause()
true
@@ -101,30 +103,41 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically,
) {
if (!isTalkbackActive()) {
- when (state.button) {
- VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
- VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
- VoiceMessageState.Button.Downloading -> ProgressButton()
- VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
- VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
+ when (state.buttonType) {
+ VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Downloading -> ProgressButton()
+ VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
}
Spacer(Modifier.width(8.dp))
- Text(
- text = state.time,
- color = ElementTheme.colors.textSecondary,
- style = ElementTheme.typography.fontBodySmMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ PlaybackSpeedButton(
+ speed = state.playbackSpeed,
+ onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
+ )
+ Text(
+ text = state.time,
+ color = ElementTheme.colors.textSecondary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
Spacer(Modifier.width(8.dp))
WaveformPlaybackView(
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
- modifier = Modifier.height(34.dp),
+ modifier = Modifier
+ .weight(1f)
+ .height(34.dp),
seekEnabled = !isTalkbackActive(),
- onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
+ onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
index 0c13214a7d..883c591fd2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
@@ -49,7 +49,7 @@ fun GroupHeaderView(
modifier: Modifier = Modifier
) {
// Ignore isHighlighted for now, we need a design decision on it.
- val backgroundColor = Color.Companion.Transparent
+ val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
index 8a19b88b26..3b0448ddb3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
@@ -112,7 +112,7 @@ fun EventDebugInfoView(
private fun prettyJSON(maybeJSON: String): String {
return try {
JSONObject(maybeJSON).toString(2)
- } catch (e: JSONException) {
+ } catch (_: JSONException) {
// Prefer not pretty-printing over crashing if the data is not actually JSON
maybeJSON
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index a3c8a1bb26..3d8467729a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -9,7 +9,6 @@
package io.element.android.features.messages.impl.timeline.factories.event
import android.text.style.URLSpan
-import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject
@@ -35,11 +34,9 @@ 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.FileMessageType
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
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.MessageFormat
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.StickerMessageType
@@ -50,6 +47,7 @@ import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+import org.jsoup.nodes.Document
import kotlin.time.Duration
@Inject
@@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory(
private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper,
) {
- suspend fun create(
+ fun create(
content: MessageContent,
senderDisambiguatedDisplayName: String,
eventId: EventId?,
@@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory(
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
- val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
- emoteBody
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(
+ permalinkParser = permalinkParser,
+ prefix = "* $senderDisambiguatedDisplayName",
+ )
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(emoteBody).safeLinkify()
TimelineItemEmoteContent(
body = emoteBody,
- htmlDocument = messageType.formatted?.toHtmlDocument(
- permalinkParser = permalinkParser,
- prefix = "* $senderDisambiguatedDisplayName",
- ),
+ htmlDocument = dom,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is ImageMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory(
)
}
is StickerMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory(
}
}
is VideoMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory(
)
}
is AudioMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
TimelineItemAudioContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory(
)
}
is VoiceMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
TimelineItemVoiceContent(
eventId = eventId,
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory(
)
}
is FileMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
- val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
- body
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent(
body = body,
@@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory(
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
- val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
- body
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(body).safeLinkify()
+ val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent(
body = body,
- htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
+ htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
@@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory(
return result?.takeIf { it.isFinite() }
}
- private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
- if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
- val result = htmlConverterProvider.provide()
- .fromHtmlToSpans(formattedBody.body.trimEnd())
+ private fun parseHtml(document: Document): CharSequence? {
+ return htmlConverterProvider.provide()
+ .fromDocumentToSpans(document)
.let { textPillificationHelper.pillify(it) }
.safeLinkify()
- return if (prefix != null) {
- buildSpannedString {
- append(prefix)
- append(" ")
- append(result)
- }
- } else {
- result
- }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
index 051ded02ae..56b0e402d2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
@@ -33,7 +33,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@@ -111,7 +111,7 @@ class DefaultVoiceMessageComposerPresenter(
}
else -> {
Timber.i("Voice message permission needed")
- permissionState.eventSink(PermissionsEvents.RequestPermissions)
+ permissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
}
@@ -176,10 +176,10 @@ class DefaultVoiceMessageComposerPresenter(
localCoroutineScope.deleteRecording()
}
VoiceMessageComposerEvent.DismissPermissionsRationale -> {
- permissionState.eventSink(PermissionsEvents.CloseDialog)
+ permissionState.eventSink(PermissionsEvent.CloseDialog)
}
VoiceMessageComposerEvent.AcceptPermissionRationale -> {
- permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
+ permissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
}
is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvent.DismissSendFailureDialog -> {
diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..c3427df591
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,84 @@
+
+
+ "Aktivnosti"
+ "Zastave"
+ "Hrana i piće"
+ "Životinje i priroda"
+ "Objekti"
+ "Emotikoni i osobe"
+ "Putovanja i mjesta"
+ "Nedavni emotikoni"
+ "Simboli"
+ "Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama."
+ "Dodirnite za promjenu kvalitete prijenosa videozapisa"
+ "Datoteka se nije mogla prenijeti."
+ "Prijenos medija za obradu nije uspio, pokušajte ponovno."
+ "Prijenos medija nije uspio, pokušajte ponovno."
+ "Maksimalna dopuštena veličina datoteke je %1$s."
+ "Datoteka je prevelika za prijenos"
+ "Stavka %1$d od %2$d"
+ "Optimiziraj kvalitetu slike"
+ "Obrada…"
+ "Blokiraj korisnika"
+ "Označite ako želite sakriti sve trenutačne i buduće poruke od ovog korisnika"
+ "Ova poruka bit će prijavljena administratoru vašeg matičnog poslužitelja. On neće moći pročitati nijednu šifriranu poruku."
+ "Razlog za prijavu ovog sadržaja"
+ "Kamera"
+ "Uslikaj"
+ "Snimi videozapis"
+ "Privitak"
+ "Biblioteka fotografija i videozapisa"
+ "Lokacija"
+ "Anketa"
+ "Oblikovanje teksta"
+ "Povijest poruka trenutačno nije dostupna."
+ "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka."
+ "Želite li ih pozvati natrag?"
+ "Sami ste u ovom razgovoru"
+ "Obavijestite cijelu sobu"
+ "Svi"
+ "Pošalji ponovno"
+ "Slanje vaše poruke nije uspjelo"
+ "Dodaj reakciju"
+ "Ovo je početak sobe %1$s."
+ "Ovo je početak ovog razgovora."
+ "Nepodržani poziv. Pitajte pozivatelja može li se služiti novom aplikacijom Element X."
+ "Prikaži manje"
+ "Poruka je kopirana"
+ "Nemate dopuštenje za objavljivanje u ovoj sobi"
+
+ - "%1$d član reagirao je s %2$s"
+ - "%1$d člana reagirala su s %2$s"
+ - "%1$d članova reagiralo je s %2$s"
+
+
+ - "Vi i %1$d član reagirali ste s %2$s"
+ - "Vi i %1$d člana reagirali ste s %2$s"
+ - "Vi i %1$d članova reagirali ste s %2$s"
+
+ "Reagirali ste s %1$s"
+ "Prikaži manje"
+ "Prikaži više"
+ "Prikaži sažetak reakcija"
+ "Novo"
+
+ - "%1$d promjena sobe"
+ - "%1$d promjene sobe"
+ - "%1$d promjena sobe"
+
+ "Prijeđi u novu sobu"
+ "Ova je soba zamijenjena i više nije aktivna"
+ "Pogledaj stare poruke"
+ "Ova je soba nastavak druge sobe"
+
+ - "%1$s, %2$s i ostalih %3$d"
+ - "%1$s, %2$s i ostalih %3$d"
+ - "%1$s, %2$s i ostalih %3$d"
+
+
+ - "%1$s tipka"
+ - "%1$s tipka"
+ - "%1$s tipka"
+
+ "%1$s i %2$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 852e2504b2..bdf84e9eec 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
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
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.crypto.historyvisible.aHistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.link.aLinkState
@@ -63,6 +64,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -85,6 +87,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -142,11 +145,7 @@ class MessagesPresenterTest {
fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
markAsReadResult = { lambdaError() }
),
typingNoticeResult = { Result.success(Unit) },
@@ -172,11 +171,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -222,11 +217,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -287,11 +278,7 @@ class MessagesPresenterTest {
val event = aMessageEvent()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
eventPermalinkResult = { Result.success("a link") },
),
typingNoticeResult = { Result.success(Unit) },
@@ -513,11 +500,7 @@ class MessagesPresenterTest {
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = liveTimeline,
typingNoticeResult = { Result.success(Unit) },
@@ -585,11 +568,7 @@ class MessagesPresenterTest {
fun `present - shows prompt to reinvite users in DM`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1))
},
@@ -618,11 +597,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1))
},
@@ -644,11 +619,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2))
},
@@ -671,11 +642,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@@ -706,11 +673,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@@ -743,11 +706,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -768,11 +727,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when inviting fails`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
@@ -806,17 +761,7 @@ class MessagesPresenterTest {
fun `present - permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
- canUserSendMessageResult = { _, messageEventType ->
- when (messageEventType) {
- MessageEventType.RoomMessage -> Result.success(true)
- MessageEventType.Reaction -> Result.success(true)
- else -> lambdaError()
- }
- },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -832,17 +777,9 @@ class MessagesPresenterTest {
fun `present - no permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
- canUserSendMessageResult = { _, messageEventType ->
- when (messageEventType) {
- MessageEventType.RoomMessage -> Result.success(false)
- MessageEventType.Reaction -> Result.success(false)
- else -> lambdaError()
- }
- },
+ roomPermissions = roomPermissions(
+ canSendMessage = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -858,11 +795,9 @@ class MessagesPresenterTest {
fun `present - permission to redact own`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOtherResult = { Result.success(false) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(
+ canRedactOther = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -879,11 +814,9 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOtherResult = { Result.success(true) },
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(false) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(
+ canRedactOwn = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -928,11 +861,7 @@ class MessagesPresenterTest {
val timeline = FakeTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -972,11 +901,7 @@ class MessagesPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -1073,11 +998,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -1114,11 +1035,7 @@ class MessagesPresenterTest {
val successorReason = "This room has been moved to a new location"
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
successorRoom = SuccessorRoom(
roomId = successorRoomId,
@@ -1142,11 +1059,7 @@ class MessagesPresenterTest {
fun `present - room without successor room has null successor info in state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(successorRoom = null)
),
typingNoticeResult = { Result.success(Unit) },
@@ -1164,11 +1077,13 @@ class MessagesPresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = FakeRoomPermissions(
+ canSendState = { true },
+ canSendMessage = { true },
+ canRedactOther = true,
+ canRedactOwn = true,
+ canPinUnpin = true,
+ ),
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
@@ -1311,16 +1226,44 @@ class MessagesPresenterTest {
}
}
+ private fun roomPermissions(
+ canStartCall: Boolean = true,
+ canRedactOther: Boolean = true,
+ canRedactOwn: Boolean = true,
+ canSendMessage: Boolean = true,
+ canSendReaction: Boolean = true,
+ canPinUnpin: Boolean = true,
+ ) = FakeRoomPermissions(
+ canSendState = { type ->
+ when (type) {
+ StateEventType.CallMember -> canStartCall
+ else -> lambdaError()
+ }
+ },
+ canSendMessage = { type ->
+ when (type) {
+ MessageEventType.RoomMessage -> canSendMessage
+ MessageEventType.Reaction -> canSendReaction
+ else -> lambdaError()
+ }
+ },
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = FakeRoomPermissions(
+ canSendState = { true },
+ canSendMessage = { true },
+ canRedactOther = true,
+ canRedactOwn = true,
+ canPinUnpin = true,
+ ),
).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
@@ -1355,6 +1298,7 @@ class MessagesPresenterTest {
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() },
+ historyVisibleStatePresenter = { aHistoryVisibleState() },
linkPresenter = { aLinkState() },
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
customReactionPresenter = { aCustomReactionState() },
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 5263182fa0..fe462fc988 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -44,6 +44,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
+import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@@ -598,6 +599,7 @@ class AttachmentsPreviewPresenterTest {
)
}
),
+ mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
@@ -619,6 +621,7 @@ class AttachmentsPreviewPresenterTest {
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
timelineMode = timelineMode,
inReplyToEventId = null,
+ mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
index aad3e0b885..96cc93ea3d 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
@@ -22,12 +22,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
+import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
-import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@@ -233,16 +233,16 @@ class DefaultMediaOptimizationSelectorPresenterTest {
private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
- sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
+ mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter(
localMedia = localMedia,
maxUploadSizeProvider = maxUploadSizeProvider,
- sessionPreferencesStore = sessionPreferencesStore,
featureFlagService = featureFlagService,
mediaExtractorFactory = mediaExtractorFactory,
+ mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt
new file mode 100644
index 0000000000..faf21720fa
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeHistoryVisibleAcknowledgementRepository(
+ private val acknowledgements: MutableMap> = mutableMapOf()
+) : HistoryVisibleAcknowledgementRepository {
+ override fun hasAcknowledged(roomId: RoomId): Flow {
+ return acknowledgements.getOrPut(roomId) {
+ MutableStateFlow(false)
+ }
+ }
+
+ override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
+ val flow = acknowledgements.getOrPut(roomId) {
+ MutableStateFlow(value)
+ }
+ flow.emit(value)
+ }
+
+ companion object {
+ /**
+ * Create the repository with a pre-existing entry.
+ */
+ fun withRoom(roomId: RoomId, acknowledged: Boolean = false): FakeHistoryVisibleAcknowledgementRepository {
+ return FakeHistoryVisibleAcknowledgementRepository(
+ mutableMapOf(
+ roomId to MutableStateFlow(acknowledged)
+ )
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt
new file mode 100644
index 0000000000..afa1992cac
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class HistoryVisibleStatePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - not visible if feature disabled`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, enabled = false, acknowledged = false)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, unencrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = false))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room joined, encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room invited, encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Invited, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, encrypted, unacknowledged`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, acknowledged = false)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.showAlert).isFalse()
+ val nextState = awaitItem()
+ assertThat(nextState.showAlert).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, encrypted, acknowledged`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, acknowledged = true)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - transition from joined + unencrypted, to shared + encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true))
+ val repository = FakeHistoryVisibleAcknowledgementRepository()
+
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false))
+
+ val presenter = HistoryVisibleStatePresenter(
+ featureFlagService,
+ repository,
+ room,
+ )
+
+ presenter.test {
+ // emitted by the feature flag service(?)
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // emitted state from room info assignment
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // room is marked as encrypted
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // room history visibility is changed to shared
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ assertThat(awaitItem().showAlert).isTrue()
+
+ // alert is acknowledged
+ repository.setAcknowledged(room.roomId, true)
+ assertThat(awaitItem().showAlert).isFalse()
+ }
+ }
+
+ private fun createHistoryVisibleStatePresenter(
+ room: JoinedRoom = FakeJoinedRoom(),
+ enabled: Boolean = true,
+ acknowledged: Boolean = false
+ ): HistoryVisibleStatePresenter {
+ return HistoryVisibleStatePresenter(
+ room = room,
+ featureFlagService = FakeFeatureFlagService(mapOf("feature.enableKeyShareOnInvite" to enabled)),
+ repository = FakeHistoryVisibleAcknowledgementRepository.withRoom(room.roomId, acknowledged)
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index 4a6e777116..2feafbc9e5 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -69,6 +69,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -991,9 +992,12 @@ class MessageComposerPresenterTest {
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
- var canUserTriggerRoomNotificationResult = true
val room = FakeJoinedRoom(
- baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(
+ canTriggerRoomNotification = true,
+ )
+ ),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@@ -1033,10 +1037,38 @@ class MessageComposerPresenterTest {
// If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
assertThat(awaitItem().suggestions).isEmpty()
+ }
+ }
- // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
- canUserTriggerRoomNotificationResult = false
+ @Test
+ fun `present - room mention suggestions no permission`() = runTest {
+ val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
+ val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
+ val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
+ val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
+ val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(
+ canTriggerRoomNotification = false,
+ )
+ ),
+ typingNoticeResult = { Result.success(Unit) }
+ ).apply {
+ givenRoomMembersState(
+ RoomMembersState.Ready(
+ persistentListOf(currentUser, invitedUser, bob, david),
+ )
+ )
+ givenRoomInfo(aRoomInfo(isDirect = false))
+ }
+ val presenter = createPresenter(room)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ // An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+ skipItems(1)
assertThat(awaitItem().suggestions)
.containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
}
@@ -1049,7 +1081,9 @@ class MessageComposerPresenterTest {
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeJoinedRoom(
- baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
+ ),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@@ -1069,7 +1103,6 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
-
// An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
index 8807951d9d..351f841fcf 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
@@ -55,9 +56,7 @@ class PinnedMessagesListPresenterTest {
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
@@ -74,9 +73,7 @@ class PinnedMessagesListPresenterTest {
fun `present - timeline failure state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -95,9 +92,7 @@ class PinnedMessagesListPresenterTest {
fun `present - empty state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
},
@@ -117,9 +112,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -146,9 +139,7 @@ class PinnedMessagesListPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -194,9 +185,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -225,9 +214,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -256,9 +243,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -295,6 +280,16 @@ class PinnedMessagesListPresenterTest {
)
}
+ private fun roomPermissions(
+ canRedactOther: Boolean = true,
+ canRedactOwn: Boolean = true,
+ canPinUnpin: Boolean = true,
+ ) = FakeRoomPermissions(
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createPinnedMessagesListPresenter(
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
room: JoinedRoom = FakeJoinedRoom(),
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 13c28da6e9..b84975c6a5 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
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -35,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -55,6 +54,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
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
@@ -66,6 +66,7 @@ import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@@ -97,9 +98,7 @@ class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineItems).isEmpty()
assertThat(initialState.isLive).isTrue()
@@ -118,9 +117,7 @@ class TimelinePresenterTest {
this.paginateLambda = paginateLambda
}
val presenter = createTimelinePresenter(timeline = timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
@@ -166,9 +163,6 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- )
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter(
@@ -176,9 +170,7 @@ class TimelinePresenterTest {
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
@@ -211,9 +203,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -252,9 +242,7 @@ class TimelinePresenterTest {
timeline = timeline,
sessionPreferencesStore = sessionPreferencesStore,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
@@ -290,9 +278,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -320,9 +306,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -339,9 +323,7 @@ class TimelinePresenterTest {
markAsReadResult = { Result.success(Unit) },
)
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@@ -390,9 +372,7 @@ class TimelinePresenterTest {
timelineItems = timelineItems,
)
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@@ -446,9 +426,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
}
@@ -462,9 +440,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
endPollAction = endPollAction,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID))
}
@@ -481,9 +457,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
@@ -500,9 +474,7 @@ class TimelinePresenterTest {
),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
skipItems(2)
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
@@ -528,16 +500,14 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
val presenter = createTimelinePresenter(
room = room,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@@ -579,15 +549,13 @@ class TimelinePresenterTest {
)
),
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { Result.success(null) },
),
),
timelineItemIndexer = timelineItemIndexer,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
advanceUntilIdle()
@@ -619,14 +587,12 @@ class TimelinePresenterTest {
),
createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@@ -668,7 +634,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@@ -679,9 +645,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -729,7 +693,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@@ -740,9 +704,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -785,7 +747,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
// Use a different thread id
threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) },
),
@@ -797,9 +759,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -846,7 +806,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
// The event is in the main timeline, not in a thread
threadRootIdForEventResult = { _ -> Result.success(null) },
),
@@ -858,9 +818,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -891,9 +849,7 @@ class TimelinePresenterTest {
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
val shield = aCriticalShield()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
@@ -929,7 +885,9 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ ),
).apply {
givenRoomMembersState(RoomMembersState.Unknown)
}
@@ -937,9 +895,7 @@ class TimelinePresenterTest {
val avatarUrl = "https://domain.com/avatar.jpg"
val presenter = createTimelinePresenter(timeline, room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last()
val event = initialState.timelineItems.first() as TimelineItem.Event
assertThat(event.senderAvatar.url).isNull()
@@ -963,15 +919,13 @@ class TimelinePresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
predecessorRoomResult = { predecessorRoom }
),
)
val presenter = createTimelinePresenter(room = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
@@ -982,14 +936,12 @@ class TimelinePresenterTest {
fun `present - timeline room info no predecessor`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
predecessorRoomResult = { null }
),
)
val presenter = createTimelinePresenter(room = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull()
}
@@ -999,7 +951,7 @@ class TimelinePresenterTest {
fun `present - timeline event navigate to room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
),
)
val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> }
@@ -1025,11 +977,32 @@ class TimelinePresenterTest {
return awaitItem()
}
+ private fun roomPermissions(
+ canRedactOther: Boolean = false,
+ canRedactOwn: Boolean = true,
+ canSendMessage: Boolean = true,
+ canSendReaction: Boolean = true,
+ canPinUnpin: Boolean = false,
+ ) = FakeRoomPermissions(
+ canSendMessage = { type ->
+ when (type) {
+ MessageEventType.RoomMessage -> canSendMessage
+ MessageEventType.Reaction -> canSendReaction
+ else -> lambdaError()
+ }
+ },
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
room: FakeJoinedRoom = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ ),
),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 160e368916..9f23388ecb 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -67,6 +67,7 @@ import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
+import org.jsoup.nodes.Document
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
- htmlConverterTransform = { expected }
+ domConverterTransform = { expected }
)
val result = sut.create(
content = createMessageContent(
@@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
- htmlConverterTransform = { expectedSpanned },
+ domConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
)
val result = sut.create(
@@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest {
private fun createTimelineItemContentMessageFactory(
htmlConverterTransform: (String) -> CharSequence = { it },
+ domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
- htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
+ htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(),
)
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt
new file mode 100644
index 0000000000..cb90db29af
--- /dev/null
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.test.pinned
+
+import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
+import kotlinx.coroutines.flow.StateFlow
+
+class FakePinnedEventsTimelineProvider(
+ private val fakeTimelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
+) : PinnedEventsTimelineProvider {
+ override fun activeTimelineFlow(): StateFlow = fakeTimelineProvider.activeTimelineFlow()
+}
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
index 75b700b58d..1277783f6a 100644
--- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
@@ -11,9 +11,11 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.wysiwyg.utils.HtmlConverter
+import org.jsoup.nodes.Document
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
+ private val transformDom: (Document) -> CharSequence = { it.html() },
) : HtmlConverterProvider {
@Composable
override fun Update() = Unit
@@ -23,6 +25,10 @@ class FakeHtmlConverterProvider(
override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html)
}
+
+ override fun fromDocumentToSpans(dom: Document): CharSequence {
+ return transformDom(dom)
+ }
}
}
}
diff --git a/features/poll/api/src/main/res/values-hr/translations.xml b/features/poll/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..117ed51c6b
--- /dev/null
+++ b/features/poll/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+
+ - "%1$d posto ukupnog broja glasova"
+ - "%1$d posto ukupnog broja glasova"
+ - "%1$d posto ukupnog broja glasova"
+
+ "Uklonit će prethodni odabir"
+ "Ovo je pobjednički odgovor"
+
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
similarity index 56%
rename from features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt
rename to features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
index 3d1c162dd3..b98ab899b9 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
@@ -10,15 +10,15 @@ package io.element.android.features.poll.impl.create
import io.element.android.libraries.matrix.api.poll.PollKind
-sealed interface CreatePollEvents {
- data object Save : CreatePollEvents
- data class Delete(val confirmed: Boolean) : CreatePollEvents
- data class SetQuestion(val question: String) : CreatePollEvents
- data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
- data object AddAnswer : CreatePollEvents
- data class RemoveAnswer(val index: Int) : CreatePollEvents
- data class SetPollKind(val pollKind: PollKind) : CreatePollEvents
- data object NavBack : CreatePollEvents
- data object ConfirmNavBack : CreatePollEvents
- data object HideConfirmation : CreatePollEvents
+sealed interface CreatePollEvent {
+ data object Save : CreatePollEvent
+ data class Delete(val confirmed: Boolean) : CreatePollEvent
+ data class SetQuestion(val question: String) : CreatePollEvent
+ data class SetAnswer(val index: Int, val text: String) : CreatePollEvent
+ data object AddAnswer : CreatePollEvent
+ data class RemoveAnswer(val index: Int) : CreatePollEvent
+ data class SetPollKind(val pollKind: PollKind) : CreatePollEvent
+ data object NavBack : CreatePollEvent
+ data object ConfirmNavBack : CreatePollEvent
+ data object HideConfirmation : CreatePollEvent
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
index 3da8c3dc53..6138bab2ae 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
@@ -97,9 +97,9 @@ class CreatePollPresenter(
val scope = rememberCoroutineScope()
- fun handleEvent(event: CreatePollEvents) {
+ fun handleEvent(event: CreatePollEvent) {
when (event) {
- is CreatePollEvents.Save -> scope.launch {
+ is CreatePollEvent.Save -> scope.launch {
if (canSave) {
repository.savePoll(
existingPollId = when (mode) {
@@ -123,7 +123,7 @@ class CreatePollPresenter(
Timber.d("Cannot create poll")
}
}
- is CreatePollEvents.Delete -> {
+ is CreatePollEvent.Delete -> {
if (mode !is CreatePollMode.EditPoll) {
return
}
@@ -139,25 +139,25 @@ class CreatePollPresenter(
navigateUp()
}
}
- is CreatePollEvents.AddAnswer -> {
+ is CreatePollEvent.AddAnswer -> {
poll = poll.withNewAnswer()
}
- is CreatePollEvents.RemoveAnswer -> {
+ is CreatePollEvent.RemoveAnswer -> {
poll = poll.withAnswerRemoved(event.index)
}
- is CreatePollEvents.SetAnswer -> {
+ is CreatePollEvent.SetAnswer -> {
poll = poll.withAnswerChanged(event.index, event.text)
}
- is CreatePollEvents.SetPollKind -> {
+ is CreatePollEvent.SetPollKind -> {
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
}
- is CreatePollEvents.SetQuestion -> {
+ is CreatePollEvent.SetQuestion -> {
poll = poll.copy(question = event.question)
}
- is CreatePollEvents.NavBack -> {
+ is CreatePollEvent.NavBack -> {
navigateUp()
}
- CreatePollEvents.ConfirmNavBack -> {
+ CreatePollEvent.ConfirmNavBack -> {
val shouldConfirm = isDirty
if (shouldConfirm) {
showBackConfirmation = true
@@ -165,7 +165,7 @@ class CreatePollPresenter(
navigateUp()
}
}
- is CreatePollEvents.HideConfirmation -> {
+ is CreatePollEvent.HideConfirmation -> {
showBackConfirmation = false
showDeleteConfirmation = false
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
index 1046f25bd5..80aa7dc4b0 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
@@ -20,7 +20,7 @@ data class CreatePollState(
val pollKind: PollKind,
val showBackConfirmation: Boolean,
val showDeleteConfirmation: Boolean,
- val eventSink: (CreatePollEvents) -> Unit,
+ val eventSink: (CreatePollEvent) -> Unit,
) {
enum class Mode {
New,
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
index 53caf33707..3abf3718af 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
@@ -62,20 +62,21 @@ fun CreatePollView(
) {
val coroutineScope = rememberCoroutineScope()
- val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
+ val navBack = { state.eventSink(CreatePollEvent.ConfirmNavBack) }
BackHandler(onBack = navBack)
if (state.showBackConfirmation) {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) },
- onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
+ onSaveClick = { state.eventSink(CreatePollEvent.Save) },
+ onDiscardClick = { state.eventSink(CreatePollEvent.NavBack) },
+ onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) },
)
}
if (state.showDeleteConfirmation) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title),
content = stringResource(id = R.string.screen_edit_poll_delete_confirmation),
- onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) },
- onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
+ onSubmitClick = { state.eventSink(CreatePollEvent.Delete(confirmed = true)) },
+ onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) }
)
}
val questionFocusRequester = remember { FocusRequester() }
@@ -90,7 +91,7 @@ fun CreatePollView(
mode = state.mode,
saveEnabled = state.canSave,
onBackClick = navBack,
- onSaveClick = { state.eventSink(CreatePollEvents.Save) }
+ onSaveClick = { state.eventSink(CreatePollEvent.Save) }
)
},
) { paddingValues ->
@@ -111,7 +112,7 @@ fun CreatePollView(
label = stringResource(id = R.string.screen_create_poll_question_desc),
value = state.question,
onValueChange = {
- state.eventSink(CreatePollEvents.SetQuestion(it))
+ state.eventSink(CreatePollEvent.SetQuestion(it))
},
modifier = Modifier
.focusRequester(questionFocusRequester)
@@ -130,7 +131,7 @@ fun CreatePollView(
TextField(
value = answer.text,
onValueChange = {
- state.eventSink(CreatePollEvents.SetAnswer(index, it))
+ state.eventSink(CreatePollEvent.SetAnswer(index, it))
},
modifier = Modifier
.then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier)
@@ -144,7 +145,7 @@ fun CreatePollView(
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
modifier = Modifier.clickable(answer.canDelete) {
- state.eventSink(CreatePollEvents.RemoveAnswer(index))
+ state.eventSink(CreatePollEvent.RemoveAnswer(index))
},
)
},
@@ -160,7 +161,7 @@ fun CreatePollView(
),
style = ListItemStyle.Primary,
onClick = {
- state.eventSink(CreatePollEvents.AddAnswer)
+ state.eventSink(CreatePollEvent.AddAnswer)
coroutineScope.launch(Dispatchers.Main) {
lazyListState.animateScrollToItem(state.answers.size + 1)
answerFocusRequester.requestFocus()
@@ -180,7 +181,7 @@ fun CreatePollView(
),
onClick = {
state.eventSink(
- CreatePollEvents.SetPollKind(
+ CreatePollEvent.SetPollKind(
if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed
)
)
@@ -190,7 +191,7 @@ fun CreatePollView(
ListItem(
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
style = ListItemStyle.Destructive,
- onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) },
+ onClick = { state.eventSink(CreatePollEvent.Delete(confirmed = false)) },
)
}
}
diff --git a/features/poll/impl/src/main/res/values-hr/translations.xml b/features/poll/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..adc11a1be7
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Dodaj mogućnost"
+ "Prikaži rezultate tek nakon završetka ankete"
+ "Sakrij glasove"
+ "Mogućnost %1$d"
+ "Vaše promjene nisu spremljene. Jeste li sigurni da se želite vratiti?"
+ "Izbriši mogućnost %1$s"
+ "Pitanje ili tema"
+ "O čemu se radi u anketi?"
+ "Izradi anketu"
+ "Jeste li sigurni da želite izbrisati ovu anketu?"
+ "Izbriši anketu"
+ "Uredi anketu"
+ "Ne mogu pronaći nijednu tekuću anketu."
+ "Ne mogu pronaći nijednu prijašnju anketu."
+ "Tekuće"
+ "Prijašnje"
+ "Ankete"
+
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
index 1f916eb670..dee0268c1c 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
@@ -104,15 +104,15 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.canSave).isFalse()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.canSave).isFalse()
- questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
+ questionSet.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
assertThat(answer1Set.canSave).isFalse()
- answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
+ answer1Set.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
assertThat(answer2Set.canSave).isTrue()
}
@@ -133,11 +133,11 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
- initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
- initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
+ initial.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
skipItems(3)
- initial.eventSink(CreatePollEvents.Save)
+ initial.eventSink(CreatePollEvent.Save)
delay(1) // Wait for the coroutine to finish
createPollResult.assertions().isCalledOnce()
.with(
@@ -182,10 +182,10 @@ class CreatePollPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?"))
- awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
- awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
- awaitItem().eventSink(CreatePollEvents.Save)
+ awaitDefaultItem().eventSink(CreatePollEvent.SetQuestion("A question?"))
+ awaitItem().eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
+ awaitItem().eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
+ awaitItem().eventSink(CreatePollEvent.Save)
delay(1) // Wait for the coroutine to finish
createPollResult.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
@@ -210,20 +210,20 @@ class CreatePollPresenterTest {
}.test {
awaitDefaultItem()
awaitPollLoaded().apply {
- eventSink(CreatePollEvents.SetQuestion("Changed question"))
+ eventSink(CreatePollEvent.SetQuestion("Changed question"))
}
awaitItem().apply {
- eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1"))
+ eventSink(CreatePollEvent.SetAnswer(0, "Changed answer 1"))
}
awaitItem().apply {
- eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2"))
+ eventSink(CreatePollEvent.SetAnswer(1, "Changed answer 2"))
}
awaitPollLoaded(
newQuestion = "Changed question",
newAnswer1 = "Changed answer 1",
newAnswer2 = "Changed answer 2",
).apply {
- eventSink(CreatePollEvents.Save)
+ eventSink(CreatePollEvent.Save)
}
advanceUntilIdle() // Wait for the coroutine to finish
@@ -275,8 +275,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
- awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
+ awaitPollLoaded().eventSink(CreatePollEvent.SetAnswer(0, "A"))
+ awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvent.Save)
advanceUntilIdle() // Wait for the coroutine to finish
editPollLambda.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
@@ -296,12 +296,12 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.answers.size).isEqualTo(2)
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
val answerAdded = awaitItem()
assertThat(answerAdded.answers.size).isEqualTo(3)
assertThat(answerAdded.answers[2].text).isEmpty()
- initial.eventSink(CreatePollEvents.RemoveAnswer(2))
+ initial.eventSink(CreatePollEvent.RemoveAnswer(2))
val answerRemoved = awaitItem()
assertThat(answerRemoved.answers.size).isEqualTo(2)
}
@@ -314,7 +314,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.question).isEqualTo("A question?")
}
@@ -327,7 +327,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "This is answer 1"))
val answerSet = awaitItem()
assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
}
@@ -340,7 +340,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
+ initial.eventSink(CreatePollEvent.SetPollKind(PollKind.Undisclosed))
val kindSet = awaitItem()
assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
}
@@ -355,10 +355,10 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.canAddAnswer).isTrue()
repeat(17) {
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().canAddAnswer).isTrue()
}
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().canAddAnswer).isFalse()
}
}
@@ -371,7 +371,7 @@ class CreatePollPresenterTest {
}.test {
val initial = awaitItem()
assertThat(initial.answers.all { it.canDelete }).isFalse()
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().answers.all { it.canDelete }).isTrue()
}
}
@@ -383,7 +383,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "A".repeat(241)))
assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
}
}
@@ -396,7 +396,7 @@ class CreatePollPresenterTest {
}.test {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
- initial.eventSink(CreatePollEvents.NavBack)
+ initial.eventSink(CreatePollEvent.NavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -410,7 +410,7 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(initial.showBackConfirmation).isFalse()
- initial.eventSink(CreatePollEvents.ConfirmNavBack)
+ initial.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -422,11 +422,11 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
+ initial.eventSink(CreatePollEvent.SetQuestion("Non blank"))
assertThat(awaitItem().showBackConfirmation).isFalse()
- initial.eventSink(CreatePollEvents.ConfirmNavBack)
+ initial.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
- initial.eventSink(CreatePollEvents.HideConfirmation)
+ initial.eventSink(CreatePollEvent.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
@@ -442,7 +442,7 @@ class CreatePollPresenterTest {
val loaded = awaitPollLoaded()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(loaded.showBackConfirmation).isFalse()
- loaded.eventSink(CreatePollEvents.ConfirmNavBack)
+ loaded.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -455,11 +455,11 @@ class CreatePollPresenterTest {
}.test {
awaitDefaultItem()
val loaded = awaitPollLoaded()
- loaded.eventSink(CreatePollEvents.SetQuestion("CHANGED"))
+ loaded.eventSink(CreatePollEvent.SetQuestion("CHANGED"))
assertThat(awaitItem().showBackConfirmation).isFalse()
- loaded.eventSink(CreatePollEvents.ConfirmNavBack)
+ loaded.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
- loaded.eventSink(CreatePollEvents.HideConfirmation)
+ loaded.eventSink(CreatePollEvent.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
@@ -474,7 +474,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
awaitDeleteConfirmation()
assert(redactEventLambda).isNeverCalled()
}
@@ -489,8 +489,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
- awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation)
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
+ awaitDeleteConfirmation().eventSink(CreatePollEvent.HideConfirmation)
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
@@ -507,8 +507,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
- awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true))
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
+ awaitDeleteConfirmation().eventSink(CreatePollEvent.Delete(confirmed = true))
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
index 438d451199..277508681e 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
@@ -220,6 +220,7 @@ class PollContentStateFactoryTest {
votes = votes,
endTime = endTime,
isEdited = false,
+ threadInfo = null,
)
private fun aPollContentState(
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
index 82ff1e7edd..5a59d9be8a 100644
--- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
@@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
+ fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToRoomNotificationSettings(roomId: RoomId)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index c7328fb6ed..c646923c77 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -163,6 +163,10 @@ class PreferencesFlowNode(
backstack.push(NavTarget.Labs)
}
+ override fun navigateToLinkNewDevice() {
+ callback.navigateToLinkNewDevice()
+ }
+
override fun navigateToUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
index b518dae4d1..c2b51973d0 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
@@ -234,7 +234,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = subtitle,
- style = ElementTheme.materialTypography.bodyMedium,
+ style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
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 3bf4f375d5..1804d7e070 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,4 +21,5 @@ sealed interface DeveloperSettingsEvents {
data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents
+ data object VacuumStores : 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 52e522dc89..a0d96be540 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
@@ -29,22 +29,28 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
+import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
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.core.data.ByteUnit
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
@@ -61,6 +67,9 @@ class DeveloperSettingsPresenter(
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
+ private val vacuumStoresUseCase: VacuumStoresUseCase,
+ private val databaseSizesUseCase: GetDatabaseSizesUseCase,
+ private val fileSizeFormatter: FileSizeFormatter,
) : Presenter {
@Composable
override fun present(): DeveloperSettingsState {
@@ -71,6 +80,9 @@ class DeveloperSettingsPresenter(
val cacheSize = remember {
mutableStateOf>(AsyncData.Uninitialized)
}
+ val databaseSizes = remember {
+ mutableStateOf>>(AsyncData.Uninitialized)
+ }
val clearCacheAction = remember {
mutableStateOf>(AsyncAction.Uninitialized)
}
@@ -94,6 +106,7 @@ class DeveloperSettingsPresenter(
}
LaunchedEffect(Unit) {
+ computeDatabaseSizes(databaseSizes)
featureFlagService.getAvailableFeatures()
.run {
// Never display room directory search in release builds for Play Store
@@ -151,12 +164,16 @@ class DeveloperSettingsPresenter(
is DeveloperSettingsEvents.SetShowColorPicker -> {
showColorPicker = event.show
}
+ DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
+ vacuumStoresUseCase()
+ }
}
}
return DeveloperSettingsState(
features = featureUiModels,
cacheSize = cacheSize.value,
+ databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
@@ -209,6 +226,27 @@ class DeveloperSettingsPresenter(
}.runCatchingUpdatingState(cacheSize)
}
+ private fun CoroutineScope.computeDatabaseSizes(databaseSizes: MutableState>>) = launch {
+ suspend {
+ databaseSizesUseCase(sessionId).getOrThrow().let { sizes ->
+ buildMap {
+ sizes.stateStore?.let { stateStoreSize ->
+ put("State store", fileSizeFormatter.format(stateStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.eventCacheStore?.let { eventCacheStoreSize ->
+ put("Event cache store", fileSizeFormatter.format(eventCacheStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.mediaStore?.let { mediaStoreSize ->
+ put("Media store", fileSizeFormatter.format(mediaStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.cryptoStore?.let { cryptoStoreSize ->
+ put("Crypto store", fileSizeFormatter.format(cryptoStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ }
+ }.toImmutableMap()
+ }.runCatchingUpdatingState(databaseSizes)
+ }
+
private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch {
suspend {
clearCacheUseCase()
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 f97270dc7a..920c8ec95c 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
@@ -15,10 +15,12 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
data class DeveloperSettingsState(
val features: ImmutableList,
val cacheSize: AsyncData,
+ val databaseSizes: AsyncData>,
val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncAction,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
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 ea16ed9f0f..9ac4fdfcc8 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
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
open class DeveloperSettingsStateProvider : PreviewParameterProvider {
@@ -47,6 +48,7 @@ fun aDeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
cacheSize = AsyncData.Success("1.2 MB"),
+ databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
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 6d34e97f63..444a391d43 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
@@ -9,6 +9,7 @@
package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
@@ -146,6 +147,33 @@ fun DeveloperSettingsView(
}
val cache = state.cacheSize
PreferenceCategory(title = "Cache") {
+ ListItem(
+ headlineContent = { Text("Database sizes") },
+ supportingContent = {
+ if (state.databaseSizes.isLoading()) {
+ Text("Computing...")
+ } else {
+ val dbSizes = state.databaseSizes.dataOrNull()
+ if (dbSizes != null && dbSizes.isNotEmpty()) {
+ Column {
+ for ((dbName, size) in dbSizes) {
+ Text("$dbName: $size")
+ }
+ }
+ } else {
+ Text("Unknown")
+ }
+ }
+ }
+ )
+ ListItem(
+ headlineContent = {
+ Text("Vacuum stores")
+ },
+ onClick = {
+ state.eventSink(DeveloperSettingsEvents.VacuumStores)
+ }
+ )
ListItem(
headlineContent = {
Text("Clear cache")
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index 7dafcfae81..6b54a763af 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -45,6 +45,7 @@ class PreferencesRootNode(
fun navigateToLockScreenSettings()
fun navigateToAdvancedSettings()
fun navigateToLabs()
+ fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
@@ -84,6 +85,7 @@ class PreferencesRootNode(
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
onOpenLabs = callback::navigateToLabs,
+ onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index 4e83f7c6b2..1e056c0bf0 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -69,6 +69,9 @@ class PreferencesRootPresenter(
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
+ val showLinkNewDevice by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
+ }.collectAsState(initial = false)
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -146,6 +149,7 @@ class PreferencesRootPresenter(
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
+ showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
index dd03b3e775..d637ae6c87 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
@@ -25,6 +25,7 @@ data class PreferencesRootState(
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
+ val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index c979bd2580..b8d1f1c2b6 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -31,6 +31,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
+ showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 66398d9dbc..5e3c9d6759 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -54,6 +54,7 @@ fun PreferencesRootView(
onAddAccountClick: () -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
@@ -101,6 +102,7 @@ fun PreferencesRootView(
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
+ onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
@@ -193,8 +195,16 @@ private fun ColumnScope.ManageAppSection(
private fun ColumnScope.ManageAccountSection(
state: PreferencesRootState,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
+ if (state.showLinkNewDevice) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
+ onClick = onLinkNewDeviceClick,
+ )
+ }
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
@@ -353,6 +363,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenAbout = {},
onSecureBackupClick = {},
onManageAccountClick = {},
+ onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt
new file mode 100644
index 0000000000..1d0de56f09
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.preferences.impl.tasks
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.matrix.api.MatrixClient
+import timber.log.Timber
+
+fun interface VacuumStoresUseCase {
+ suspend operator fun invoke()
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultVacuumStoresUseCase(
+ private val matrixClient: MatrixClient,
+) : VacuumStoresUseCase {
+ override suspend fun invoke() {
+ matrixClient.performDatabaseVacuum()
+ .onFailure { Timber.e(it, "Failed to vacuum stores") }
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
similarity index 70%
rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
index f7f2ffceb4..d88eb75963 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
@@ -10,10 +10,10 @@ package io.element.android.features.preferences.impl.user.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction
-sealed interface EditUserProfileEvents {
- data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
- data class UpdateDisplayName(val name: String) : EditUserProfileEvents
- data object Exit : EditUserProfileEvents
- data object Save : EditUserProfileEvents
- data object CloseDialog : EditUserProfileEvents
+sealed interface EditUserProfileEvent {
+ data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvent
+ data class UpdateDisplayName(val name: String) : EditUserProfileEvent
+ data object Exit : EditUserProfileEvent
+ data object Save : EditUserProfileEvent
+ data object CloseDialog : EditUserProfileEvent
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
index 59607139d7..bddae2fffb 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
@@ -35,7 +35,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -112,22 +112,22 @@ class EditUserProfilePresenter(
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
- fun handleEvent(event: EditUserProfileEvents) {
+ fun handleEvent(event: EditUserProfileEvent) {
when (event) {
- is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
+ is EditUserProfileEvent.Save -> localCoroutineScope.saveChanges(
name = userDisplayName,
avatarUri = userAvatarUri?.toUri(),
currentUser = matrixUser,
action = saveAction,
)
- is EditUserProfileEvents.HandleAvatarAction -> {
+ is EditUserProfileEvent.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
AvatarAction.Remove -> {
temporaryUriDeleter.delete(userAvatarUri?.toUri())
@@ -135,8 +135,8 @@ class EditUserProfilePresenter(
}
}
}
- is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
- EditUserProfileEvents.Exit -> {
+ is EditUserProfileEvent.UpdateDisplayName -> userDisplayName = event.name
+ EditUserProfileEvent.Exit -> {
when (saveAction.value) {
is AsyncAction.Confirming -> {
// Close the dialog right now
@@ -157,7 +157,7 @@ class EditUserProfilePresenter(
}
}
}
- EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
+ EditUserProfileEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
index 9becf6ce12..a638ed8378 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
@@ -22,5 +22,5 @@ data class EditUserProfileState(
val saveButtonEnabled: Boolean,
val saveAction: AsyncAction,
val cameraPermissionState: PermissionsState,
- val eventSink: (EditUserProfileEvents) -> Unit
+ val eventSink: (EditUserProfileEvent) -> Unit
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
index 56b734a342..ca9571aea5 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
@@ -33,7 +33,7 @@ fun aEditUserProfileState(
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
- eventSink: (EditUserProfileEvents) -> Unit = {},
+ eventSink: (EditUserProfileEvent) -> Unit = {},
) = EditUserProfileState(
userId = userId,
displayName = displayName,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
index d6f0fcbd2c..e997f08d65 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
@@ -34,6 +34,7 @@ import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -47,7 +48,8 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
-import io.element.android.libraries.matrix.ui.components.EditableAvatarView
+import io.element.android.libraries.matrix.ui.components.AvatarPickerState
+import io.element.android.libraries.matrix.ui.components.AvatarPickerView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@@ -68,7 +70,7 @@ fun EditUserProfileView(
fun onBackClick() {
focusManager.clearFocus()
- state.eventSink(EditUserProfileEvents.Exit)
+ state.eventSink(EditUserProfileEvent.Exit)
}
BackHandler(
@@ -87,7 +89,7 @@ fun EditUserProfileView(
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
- state.eventSink(EditUserProfileEvents.Save)
+ state.eventSink(EditUserProfileEvent.Save)
},
)
}
@@ -103,13 +105,17 @@ fun EditUserProfileView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
- EditableAvatarView(
- matrixId = state.userId.value,
- displayName = state.displayName,
- avatarUrl = state.userAvatarUrl,
- avatarSize = AvatarSize.EditProfileDetails,
- avatarType = AvatarType.User,
- onAvatarClick = { onAvatarClick() },
+ val avatarPickerState = remember(state.userAvatarUrl) {
+ val size = AvatarSize.EditProfileDetails
+ val type = AvatarType.User
+ AvatarPickerState.Selected(
+ avatarData = AvatarData(id = state.userId.value, name = state.displayName, size = size, url = state.userAvatarUrl),
+ type = type
+ )
+ }
+ AvatarPickerView(
+ state = avatarPickerState,
+ onClick = ::onAvatarClick,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(16.dp))
@@ -125,7 +131,7 @@ fun EditUserProfileView(
value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
- onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) },
+ onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) },
)
}
@@ -133,7 +139,7 @@ fun EditUserProfileView(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
- onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
+ onSelectAction = { state.eventSink(EditUserProfileEvent.HandleAvatarAction(it)) }
)
AsyncActionView(
@@ -147,8 +153,9 @@ fun EditUserProfileView(
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) },
- onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }
+ onSaveClick = { state.eventSink(EditUserProfileEvent.Save) },
+ onDiscardClick = { state.eventSink(EditUserProfileEvent.Exit) },
+ onDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) },
)
}
}
@@ -156,7 +163,7 @@ fun EditUserProfileView(
onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
- onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
+ onErrorDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) },
)
}
PermissionsView(
diff --git a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
index b1f615e697..9f69227bec 100644
--- a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
+++ b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
@@ -3,4 +3,5 @@
"Optimize media quality"
"Automatically optimize images for faster uploads and smaller file sizes."
"Optimize image upload quality"
+ "Try out our latest ideas in development. These features are not finalized; they may be unstable, may change."
diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..250af33d49
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,82 @@
+
+
+ "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan."
+ "Poboljšajte svoje iskustvo poziva"
+ "Odaberite kako želite primati obavijesti"
+ "Način rada za razvojne inženjere"
+ "Omogućite pristup značajkama i funkcionalnostima za razvojne inženjere."
+ "Prilagođeni osnovni URL za Element Call"
+ "Postavite prilagođeni osnovni URL za Element Call."
+ "Nevažeći URL; provjerite jeste li uključili protokol (http/https) i ispravnu adresu."
+ "Sakrij avatare u zahtjevima za poziv u sobu"
+ "Sakrij preglede medija na vremenskoj traci"
+ "Laboratoriji"
+ "Brže prenesite fotografije i videozapise te smanjite potrošnju podataka"
+ "Optimiziraj kvalitetu medija"
+ "Moderiranje i sigurnost"
+ "Automatski optimizirajte slike za brži prijenos i manje veličine datoteka."
+ "Optimiziraj kvalitetu prijenosa slika"
+ "%1$s. Ovdje dodirnite za promjenu."
+ "Visoka (1080p)"
+ "Niska (480p)"
+ "Standardna (720p)"
+ "Kvaliteta prijenosa videozapisa"
+ "Pružatelj push obavijesti"
+ "Onemogućite uređivač obogaćenog teksta kako biste ručno tipkali Markdown."
+ "Potvrde o čitanju"
+ "Ako je to isključeno, vaše potvrde o čitanju neće se slati nikome. I dalje ćete primati potvrde o čitanju od drugih korisnika."
+ "Podijeli prisutnost"
+ "Ako je to isključeno, nećete moći slati ili primati potvrde o čitanju ili obavijesti o tipkanju."
+ "Uvijek sakrij"
+ "Uvijek prikaži"
+ "U privatnim sobama"
+ "Skriveni medij uvijek se može prikazati tako se da se dodirne"
+ "Prikaži medije na vremenskoj traci"
+ "Omogući opciju za prikaz izvora poruke na vremenskoj traci."
+ "Nemate blokiranih korisnika"
+ "Odblokiraj"
+ "Moći ćete ponovno vidjeti sve njihove poruke."
+ "Odblokiraj korisnika"
+ "Deblokiranje…"
+ "Ime za prikaz"
+ "Vaše ime za prikaz"
+ "Došlo je do nepoznate pogreške i informacije se nisu mogle promijeniti."
+ "Nije moguće ažurirati profil"
+ "Uredi profil"
+ "Ažuriranje profila…"
+ "Omogući odgovore u nizu"
+ "Aplikacija će se ponovno pokrenuti kako bi se primijenila ova promjena."
+ "Isprobajte naše najnovije ideje u razvoju. Ove značajke nisu finalizirane; mogu biti nestabilne i mijenjati se."
+ "Jeste li spremni za eksperimentiranje?"
+ "Laboratoriji"
+ "Dodatne postavke"
+ "Audiopozivi i videopozivi"
+ "Neusklađenost konfiguracije"
+ "Pojednostavili smo postavke obavijesti kako bismo olakšali pronalaženje mogućnosti. Neke prilagođene postavke koje ste odabrali u prošlosti nisu ovdje prikazane, ali su i dalje aktivne.
+
+Ako nastavite, neke od vaših postavki mogu se promijeniti."
+ "Izravni razgovori"
+ "Prilagođena postavka po razgovoru"
+ "Došlo je do pogreške prilikom ažuriranja postavke obavijesti."
+ "Sve poruke"
+ "Samo spominjanja i ključne riječi"
+ "U izravnim razgovorima obavijesti me za"
+ "U grupnim chatovima obavijesti me za"
+ "Omogući obavijesti na ovom uređaju"
+ "Konfiguracija nije ispravljena, pokušajte ponovno."
+ "Grupni razgovori"
+ "Pozivnice"
+ "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama."
+ "Spominjanja"
+ "Sve"
+ "Spominjanja"
+ "Obavijesti me za"
+ "Obavijesti me o sobi @soba"
+ "Kako biste primali obavijesti, promijenite %1$s."
+ "postavke sustava"
+ "Obavijesti sustava su isključene"
+ "Obavijesti"
+ "Povijest push obavijesti"
+ "Rješavanje problema"
+ "Rješavanje problema s obavijestima"
+
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
index 7a950629be..963d7846b4 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
@@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
+ override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError()
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 fe2d8445fd..1fcf9bff70 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
@@ -17,15 +17,20 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
+import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
+import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -34,6 +39,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -55,7 +61,12 @@ class DeveloperSettingsPresenterTest {
)
}
val presenter = createDeveloperSettingsPresenter(
- featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult)
+ featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
+ databaseSizesUseCase = GetDatabaseSizesUseCase {
+ Result.success(
+ SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes)
+ )
+ }
)
presenter.test {
awaitItem().also { state ->
@@ -78,6 +89,14 @@ class DeveloperSettingsPresenterTest {
}
awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
+ assertThat(state.databaseSizes.dataOrNull()).isEqualTo(
+ persistentMapOf(
+ "State store" to "10485760 Bytes",
+ "Event cache store" to "10485760 Bytes",
+ "Media store" to "10485760 Bytes",
+ "Crypto store" to "10485760 Bytes"
+ )
+ )
}
getAvailableFeaturesResult.assertions().isCalledOnce()
.with(value(false), value(false))
@@ -212,6 +231,23 @@ class DeveloperSettingsPresenterTest {
}
}
+ @Test
+ fun `present - VacuumStores action invokes the VacuumStoresUseCase`() = runTest {
+ var vacuumCalled = false
+ val presenter = createDeveloperSettingsPresenter(
+ vacuumStoresUseCase = VacuumStoresUseCase {
+ vacuumCalled = true
+ }
+ )
+ presenter.test {
+ val state = awaitItem()
+ assertThat(vacuumCalled).isFalse()
+ state.eventSink(DeveloperSettingsEvents.VacuumStores)
+ skipItems(1)
+ assertThat(vacuumCalled).isTrue()
+ }
+ }
+
private fun createDeveloperSettingsPresenter(
sessionId: SessionId = A_SESSION_ID,
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
@@ -230,6 +266,8 @@ class DeveloperSettingsPresenterTest {
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
+ vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
+ databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
sessionId = sessionId,
@@ -240,6 +278,9 @@ class DeveloperSettingsPresenterTest {
appPreferencesStore = preferencesStore,
buildMeta = buildMeta,
enterpriseService = enterpriseService,
+ vacuumStoresUseCase = vacuumStoresUseCase,
+ databaseSizesUseCase = databaseSizesUseCase,
+ fileSizeFormatter = FakeFileSizeFormatter(),
)
}
}
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 e812bf650d..3854e3f4a1 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
@@ -113,7 +113,7 @@ class DeveloperSettingsViewTest {
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG))
}
- @Config(qualifiers = "h2000dp")
+ @Config(qualifiers = "h2200dp")
@Test
fun `clicking on clear cache emits the expected event`() {
val eventsRecorder = EventsRecorder()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 1fa141b297..d10f860f0d 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -87,6 +87,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse()
+ assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
@@ -258,6 +259,22 @@ class PreferencesRootPresenterTest {
}
}
+ @Test
+ fun `present - link new device`() = runTest {
+ createPresenter(
+ matrixClient = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ canDeactivateAccountResult = { true },
+ ),
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.QrCodeLogin.key to true)
+ ),
+ ).test {
+ val state = awaitFirstItem()
+ assertThat(state.showLinkNewDevice).isTrue()
+ }
+ }
+
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
index 3432bac29f..0602709ec8 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
@@ -56,8 +56,6 @@ class EditUserProfilePresenterTest {
private val userAvatarUri: Uri = mockk()
private val anotherAvatarUri: Uri = mockk()
- private val fakeFileContents = ByteArray(2)
-
@Before
fun setup() {
fakePickerProvider = FakePickerProvider()
@@ -124,7 +122,7 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.Exit)
+ initialState.eventSink(EditUserProfileEvent.Exit)
closeLambda.assertions().isCalledOnce()
}
}
@@ -139,21 +137,21 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name"))
val withUpdatedName = awaitItem()
- withUpdatedName.eventSink(EditUserProfileEvents.Exit)
+ withUpdatedName.eventSink(EditUserProfileEvent.Exit)
val withConfirmation = awaitItem()
assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
// Cancel
- withConfirmation.eventSink(EditUserProfileEvents.CloseDialog)
+ withConfirmation.eventSink(EditUserProfileEvent.CloseDialog)
val afterCancel = awaitItem()
assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized)
// Try again and confirm
- afterCancel.eventSink(EditUserProfileEvents.Exit)
+ afterCancel.eventSink(EditUserProfileEvent.Exit)
val withConfirmation2 = awaitItem()
assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
closeLambda.assertions().isNeverCalled()
- withConfirmation2.eventSink(EditUserProfileEvents.Exit)
+ withConfirmation2.eventSink(EditUserProfileEvent.Exit)
// Dialog is closed
val finalState = awaitItem()
assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized)
@@ -174,17 +172,17 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.displayName).isEqualTo("Name")
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name II")
assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL)
}
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name III"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL)
}
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isNull()
@@ -205,7 +203,7 @@ class EditUserProfilePresenterTest {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL)
}
@@ -229,7 +227,7 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithAskingPermission = awaitItem()
assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted()
@@ -239,7 +237,7 @@ class EditUserProfilePresenterTest {
assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL)
// Do it again, no permission is requested
fakePickerProvider.givenResult(userAvatarUri)
- stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ stateWithNewAvatar.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
deleteCallback.assertions().isCalledExactly(2).withSequence(
@@ -264,22 +262,22 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
@@ -305,22 +303,22 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
@@ -344,9 +342,9 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name"))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.Save)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isTrue()
assertThat(matrixClient.removeAvatarCalled).isTrue()
@@ -365,8 +363,8 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName(" Name "))
+ initialState.eventSink(EditUserProfileEvent.Save)
consumeItemsUntilTimeout()
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
@@ -384,8 +382,8 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName(""))
+ initialState.eventSink(EditUserProfileEvent.Save)
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(matrixClient.removeAvatarCalled).isFalse()
@@ -397,7 +395,7 @@ class EditUserProfilePresenterTest {
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user,
@@ -405,12 +403,16 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
- initialState.eventSink(EditUserProfileEvents.Save)
- consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
- assertThat(matrixClient.uploadAvatarCalled).isTrue()
+ try {
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.Save)
+ consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
+ assertThat(matrixClient.uploadAvatarCalled).isTrue()
+ }
+ } finally {
+ tmpFile.delete()
}
}
@@ -429,8 +431,8 @@ class EditUserProfilePresenterTest {
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.Save)
skipItems(2)
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -443,7 +445,7 @@ class EditUserProfilePresenterTest {
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name"))
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.UpdateDisplayName("New name"))
}
@Test
@@ -452,39 +454,47 @@ class EditUserProfilePresenterTest {
val matrixClient = FakeMatrixClient().apply {
givenRemoveAvatarResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
}
@Test
fun `present - sets save action to failure if setting avatar fails`() = runTest {
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenUploadAvatarResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ try {
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ } finally {
+ tmpFile.delete()
+ }
}
@Test
fun `present - CloseDialog resets save action state`() = runTest {
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
}
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
- initialState.eventSink(EditUserProfileEvents.Save)
- skipItems(2)
- assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
- initialState.eventSink(EditUserProfileEvents.CloseDialog)
- assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ try {
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("foo"))
+ initialState.eventSink(EditUserProfileEvent.Save)
+ skipItems(2)
+ assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ initialState.eventSink(EditUserProfileEvent.CloseDialog)
+ assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ } finally {
+ tmpFile.delete()
}
}
- private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
+ private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvent) {
val presenter = createEditUserProfilePresenter(
matrixUser = matrixUser,
matrixClient = matrixClient,
@@ -495,27 +505,25 @@ class EditUserProfilePresenterTest {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(event)
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
- private fun givenPickerReturnsFile() {
- mockkStatic(File::readBytes)
- val processedFile: File = mockk {
- every { readBytes() } returns fakeFileContents
- }
+ private fun givenPickerReturnsFile(): File {
+ val file = File.createTempFile("test", "jpg")
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(
Result.success(
MediaUploadInfo.AnyFile(
- file = processedFile,
+ file = file,
fileInfo = mockk(),
)
)
)
+ return file
}
companion object {
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
index f4c7144350..728e05ee7e 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
@@ -34,45 +34,45 @@ class EditUserProfileViewTest {
@Test
fun `clicking on back emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
eventSink = eventsRecorder,
),
)
rule.pressBack()
- eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
}
@Test
- fun `clicking on cancel exit emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ fun `clicking on save from the exit confirmation dialog emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog)
+ rule.clickOn(CommonStrings.action_save, inDialog = true)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Save)
}
@Test
- fun `clicking on OK exit emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ fun `clicking on discard exit emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
- eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
+ rule.clickOn(CommonStrings.action_discard)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
}
@Test
fun `clicking on save emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveButtonEnabled = true,
@@ -81,12 +81,12 @@ class EditUserProfileViewTest {
),
)
rule.clickOn(CommonStrings.action_save)
- eventsRecorder.assertSingle(EditUserProfileEvents.Save)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Save)
}
@Test
fun `clicking on avatar opens the bottom sheet dialog`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val actions = listOf(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
@@ -110,7 +110,7 @@ class EditUserProfileViewTest {
@Test
fun `success invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
+ val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
rule.setEditUserProfileView(
aEditUserProfileState(
diff --git a/features/rageshake/api/src/main/res/values-hr/translations.xml b/features/rageshake/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..c3dfcb3203
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?"
+ "Čini se da ljutito treseš telefon. Želiš li otvoriti zaslon s izvješćem o pogrešci?"
+ "Snažno protresi"
+ "Prag detekcije"
+
diff --git a/features/rageshake/impl/src/main/res/values-hr/translations.xml b/features/rageshake/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..e49427b2a9
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Priloži snimku zaslona"
+ "Možete mi se obratiti ako imate bilo kakvih dodatnih pitanja."
+ "Javi mi se"
+ "Uredi snimku zaslona"
+ "Opišite problem. Što ste napravili? Što ste očekivali da će se dogoditi? Što se zapravo dogodilo. Molimo vas da što detaljnije opišete problem."
+ "Opišite problem…"
+ "Ako je moguće, molimo vas da opis bude na engleskom jeziku."
+ "Opis je prekratak; navedite više pojedinosti o tome što se dogodilo. Hvala!"
+ "Pošalji zapisnike o padu aplikacije"
+ "Dopusti zapisnike"
+ "Vaši su zapisnici preopširni pa ih nije moguće uključiti u ovo izvješće. Molimo vas da nam ih pošaljete na drugi način."
+ "Pošalji snimku zaslona"
+ "Zapisnici će biti uključeni u vašu poruku kako bismo bili sigurni da sve ispravno funkcionira. Kako biste poslali poruku bez zapisnika, isključite ovu postavku."
+ "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?"
+ "Ako imate problema s obavijestima, prijenos pravila za slanje obavijesti može nam pomoći da utvrdimo uzrok. Imajte na umu da ta pravila mogu sadržavati privatne podatke, kao što su vaše ime za prikaz ili ključne riječi za koje želite primati obavijesti."
+ "Postavke slanja obavijesti"
+ "Prikaz zapisnika"
+
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
index f82c57889f..41e136a53e 100755
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
@@ -323,7 +323,7 @@ class DefaultBugReporterTest {
while (part != null) {
part.headers["Content-Disposition"]?.let { contentDisposition ->
regex.find(contentDisposition)?.groupValues?.get(1)?.let { name ->
- foundValues.put(name, part!!.body.readUtf8())
+ foundValues.put(name, part.body.readUtf8())
}
}
part = multipartReader.nextPart()
diff --git a/features/reportroom/impl/src/main/res/values-hr/translations.xml b/features/reportroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..50ecc7a24d
--- /dev/null
+++ b/features/reportroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Vaša je prijava uspješno poslana, ali naišli smo na problem prilikom pokušaja napuštanja sobe. Pokušajte ponovno."
+ "Sobu nije moguće napustiti"
+ "Prijavi ovu sobu svom administratoru. Ako su poruke šifrirane, vaš administrator neće ih moći pročitati."
+ "Navedite razlog prijave…"
+ "Prijavi sobu"
+
diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
index 1e9fe6a1c4..bd3cea0439 100644
--- a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
+++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
@@ -8,6 +8,19 @@
package io.element.android.features.rolesandpermissions.api
-import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
-fun interface RolesAndPermissionsEntryPoint : SimpleFeatureEntryPoint
+fun interface RolesAndPermissionsEntryPoint : FeatureEntryPoint {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: Callback,
+ ): Node
+}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
index 2f281a596a..5f27365857 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
@@ -17,7 +17,11 @@ import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class)
class DefaultRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint {
- override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
- return parentNode.createNode(buildContext)
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: RolesAndPermissionsEntryPoint.Callback,
+ ): Node {
+ return parentNode.createNode(buildContext, listOf(callback))
}
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
index 5966a4f0ed..4bd88071c3 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
@@ -14,7 +14,10 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -25,17 +28,24 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
+import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.features.rolesandpermissions.impl.permissions.ChangeRoomPermissionsNode
import io.element.android.features.rolesandpermissions.impl.roles.ChangeRolesNode
import io.element.android.features.rolesandpermissions.impl.root.RolesAndPermissionsNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -44,6 +54,7 @@ import kotlinx.parcelize.Parcelize
class RolesAndPermissionsFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ private val room: JoinedRoom,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -66,6 +77,7 @@ class RolesAndPermissionsFlowNode(
data object ChangeRoomPermissions : NavTarget
}
+ private val callback: RolesAndPermissionsEntryPoint.Callback = callback()
private val asyncIndicatorState = AsyncIndicatorState()
override fun onBuilt() {
@@ -76,6 +88,15 @@ class RolesAndPermissionsFlowNode(
onChangeComplete(changesSaved)
}
}
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ room.permissionsFlow(false) { perms -> perms.canEditRolesAndPermissions() }
+ .filter { canEdit -> !canEdit }
+ .first()
+ // If the user can no longer edit roles and permissions, exit the flow
+ callback.onDone()
+ }
+ }
}
private fun onChangeComplete(changesSaved: Boolean) {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
index 9963a751f1..546d54c21a 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
@@ -34,8 +34,8 @@ internal fun AnalyticsService.trackPermissionChangeAnalytics(initial: RoomPowerL
if (updated.kick != initial?.kick) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, analyticsMemberRoleForPowerLevel(updated.kick)))
}
- if (updated.sendEvents != initial?.sendEvents) {
- capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.sendEvents)))
+ if (updated.eventsDefault != initial?.eventsDefault) {
+ capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.eventsDefault)))
}
if (updated.redactEvents != initial?.redactEvents) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, analyticsMemberRoleForPowerLevel(updated.redactEvents)))
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
index b356ca33d2..552f1d0ec6 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -20,9 +21,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
+import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
@@ -36,8 +39,7 @@ class ChangeRoomPermissionsPresenter(
) : Presenter {
companion object {
private fun itemsForSection(section: RoomPermissionsSection) = when (section) {
- RoomPermissionsSection.SpaceDetails,
- RoomPermissionsSection.RoomDetails -> persistentListOf(
+ RoomPermissionsSection.EditDetails -> persistentListOf(
RoomPermissionType.ROOM_NAME,
RoomPermissionType.ROOM_AVATAR,
RoomPermissionType.ROOM_TOPIC,
@@ -46,19 +48,22 @@ class ChangeRoomPermissionsPresenter(
RoomPermissionType.SEND_EVENTS,
RoomPermissionType.REDACT_EVENTS,
)
- RoomPermissionsSection.MembershipModeration -> persistentListOf(
+ RoomPermissionsSection.ManageMembers -> persistentListOf(
RoomPermissionType.INVITE,
RoomPermissionType.KICK,
RoomPermissionType.BAN,
)
+ RoomPermissionsSection.ManageSpace -> persistentListOf(
+ RoomPermissionType.SPACE_MANAGE_ROOMS,
+ )
}
private fun RoomPermissionsSection.shouldShow(isSpace: Boolean): Boolean {
return when (this) {
- RoomPermissionsSection.RoomDetails -> !isSpace
- RoomPermissionsSection.MembershipModeration -> true
+ RoomPermissionsSection.EditDetails -> true
+ RoomPermissionsSection.ManageMembers -> true
RoomPermissionsSection.MessagesAndContent -> !isSpace
- RoomPermissionsSection.SpaceDetails -> isSpace
+ RoomPermissionsSection.ManageSpace -> isSpace
}
}
@@ -73,8 +78,7 @@ class ChangeRoomPermissionsPresenter(
private var initialPermissions by mutableStateOf(null)
private var currentPermissions by mutableStateOf(null)
- private var saveAction by mutableStateOf>(AsyncAction.Uninitialized)
- private var confirmExitAction by mutableStateOf>(AsyncAction.Uninitialized)
+ private var saveAction by mutableStateOf>(AsyncAction.Uninitialized)
@Composable
override fun present(): ChangeRoomPermissionsState {
@@ -88,6 +92,10 @@ class ChangeRoomPermissionsPresenter(
derivedStateOf { initialPermissions != currentPermissions }
}
+ val ownPowerLevel by remember {
+ room.roomInfoFlow.mapState { it.powerLevelOf(room.sessionId) }
+ }.collectAsState()
+
fun handleEvent(event: ChangeRoomPermissionsEvent) {
when (event) {
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
@@ -100,33 +108,33 @@ class ChangeRoomPermissionsPresenter(
RoomPermissionType.BAN -> currentPermissions?.copy(ban = powerLevel)
RoomPermissionType.INVITE -> currentPermissions?.copy(invite = powerLevel)
RoomPermissionType.KICK -> currentPermissions?.copy(kick = powerLevel)
- RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = powerLevel)
+ RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(eventsDefault = powerLevel)
RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = powerLevel)
RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = powerLevel)
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel)
}
}
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
is ChangeRoomPermissionsEvent.Exit -> {
- confirmExitAction = if (!hasChanges || confirmExitAction.isConfirming()) {
- AsyncAction.Success(Unit)
+ saveAction = if (!hasChanges || saveAction == AsyncAction.ConfirmingCancellation) {
+ AsyncAction.Success(false)
} else {
- AsyncAction.ConfirmingNoParams
+ AsyncAction.ConfirmingCancellation
}
}
is ChangeRoomPermissionsEvent.ResetPendingActions -> {
saveAction = AsyncAction.Uninitialized
- confirmExitAction = AsyncAction.Uninitialized
}
}
}
return ChangeRoomPermissionsState(
+ ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection,
hasChanges = hasChanges,
saveAction = saveAction,
- confirmExitAction = confirmExitAction,
eventSink = ::handleEvent,
)
}
@@ -147,7 +155,7 @@ class ChangeRoomPermissionsPresenter(
.onSuccess {
analyticsService.trackPermissionChangeAnalytics(initialPermissions, updatedRoomPowerLevels)
initialPermissions = currentPermissions
- saveAction = AsyncAction.Success(Unit)
+ saveAction = AsyncAction.Success(true)
}
.onFailure {
saveAction = AsyncAction.Failure(it)
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
index 2dc2c816d6..535f2b4a71 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
@@ -18,41 +18,62 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
data class ChangeRoomPermissionsState(
+ private val ownPowerLevel: Long,
val currentPermissions: RoomPowerLevelsValues?,
val itemsBySection: ImmutableMap>,
val hasChanges: Boolean,
- val saveAction: AsyncAction,
- val confirmExitAction: AsyncAction,
+ val saveAction: AsyncAction,
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
) {
+ private val ownRole = RoomMember.Role.forPowerLevel(ownPowerLevel)
+
+ // Roles that the user can select based on their own role
+ val selectableRoles: ImmutableList = when (ownRole) {
+ is RoomMember.Role.Owner,
+ RoomMember.Role.Admin -> persistentListOf(SelectableRole.Admin, SelectableRole.Moderator, SelectableRole.Everyone)
+ RoomMember.Role.Moderator -> persistentListOf(SelectableRole.Moderator, SelectableRole.Everyone)
+ RoomMember.Role.User -> persistentListOf(SelectableRole.Everyone)
+ }
+
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
- if (currentPermissions == null) return null
- val role = when (type) {
- RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban)
- RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite)
- RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick)
- RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.sendEvents)
- RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents)
- RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName)
- RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar)
- RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic)
- }
- return when (role) {
+ val powerLevel = currentPowerLevelForType(type = type) ?: return null
+ return when (RoomMember.Role.forPowerLevel(powerLevel)) {
is RoomMember.Role.Owner,
RoomMember.Role.Admin -> SelectableRole.Admin
RoomMember.Role.Moderator -> SelectableRole.Moderator
RoomMember.Role.User -> SelectableRole.Everyone
}
}
+
+ fun canChangePermission(type: RoomPermissionType): Boolean {
+ val currentPowerLevel = currentPowerLevelForType(type) ?: return false
+ return ownPowerLevel >= currentPowerLevel
+ }
+
+ private fun currentPowerLevelForType(type: RoomPermissionType): Long? {
+ if (currentPermissions == null) return null
+ return when (type) {
+ RoomPermissionType.BAN -> currentPermissions.ban
+ RoomPermissionType.INVITE -> currentPermissions.invite
+ RoomPermissionType.KICK -> currentPermissions.kick
+ RoomPermissionType.SEND_EVENTS -> currentPermissions.eventsDefault
+ RoomPermissionType.REDACT_EVENTS -> currentPermissions.redactEvents
+ RoomPermissionType.ROOM_NAME -> currentPermissions.roomName
+ RoomPermissionType.ROOM_AVATAR -> currentPermissions.roomAvatar
+ RoomPermissionType.ROOM_TOPIC -> currentPermissions.roomTopic
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions.spaceChild
+ }
+ }
}
enum class RoomPermissionsSection {
- SpaceDetails,
- RoomDetails,
+ ManageMembers,
+ EditDetails,
MessagesAndContent,
- MembershipModeration,
+ ManageSpace
}
enum class SelectableRole : DropdownOption {
@@ -81,5 +102,6 @@ enum class RoomPermissionType {
REDACT_EVENTS,
ROOM_NAME,
ROOM_AVATAR,
- ROOM_TOPIC
+ ROOM_TOPIC,
+ SPACE_MANAGE_ROOMS,
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
index d64c85f8cf..2760272d8a 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
@@ -19,29 +19,31 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider
get() = sequenceOf(
aChangeRoomPermissionsState(),
+ aChangeRoomPermissionsState(ownPowerLevel = RoomMember.Role.Moderator.powerLevel),
aChangeRoomPermissionsState(hasChanges = true),
aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
aChangeRoomPermissionsState(
hasChanges = true,
saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes"))
),
- aChangeRoomPermissionsState(hasChanges = true, confirmExitAction = AsyncAction.ConfirmingNoParams),
+ aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.ConfirmingCancellation),
+ aChangeRoomPermissionsState(itemsBySection = ChangeRoomPermissionsPresenter.buildItems(isSpace = true)),
)
}
internal fun aChangeRoomPermissionsState(
+ ownPowerLevel: Long = RoomMember.Role.Admin.powerLevel,
currentPermissions: RoomPowerLevelsValues = previewPermissions(),
itemsBySection: Map> = ChangeRoomPermissionsPresenter.buildItems(false),
hasChanges: Boolean = false,
- saveAction: AsyncAction = AsyncAction.Uninitialized,
- confirmExitAction: AsyncAction = AsyncAction.Uninitialized,
+ saveAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
) = ChangeRoomPermissionsState(
+ ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection.toImmutableMap(),
hasChanges = hasChanges,
saveAction = saveAction,
- confirmExitAction = confirmExitAction,
eventSink = eventSink,
)
@@ -53,12 +55,13 @@ private fun previewPermissions(): RoomPowerLevelsValues {
ban = RoomMember.Role.User.powerLevel,
// MessagesAndContent section
redactEvents = RoomMember.Role.Moderator.powerLevel,
- sendEvents = RoomMember.Role.Admin.powerLevel,
+ eventsDefault = RoomMember.Role.Admin.powerLevel,
// RoomDetails section
roomName = RoomMember.Role.Admin.powerLevel,
roomAvatar = RoomMember.Role.Moderator.powerLevel,
roomTopic = RoomMember.Role.User.powerLevel,
// SpaceManagement section
spaceChild = RoomMember.Role.Moderator.powerLevel,
+ stateDefault = RoomMember.Role.Moderator.powerLevel,
)
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
index 1e88d091c8..529df0d50d 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
@@ -18,9 +18,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rolesandpermissions.impl.R
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -29,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
-import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,7 +73,8 @@ fun ChangeRoomPermissionsView(
PreferenceDropdown(
title = titleForType(permissionType),
selectedOption = state.selectedRoleForType(permissionType),
- options = SelectableRole.entries.toImmutableList(),
+ options = state.selectableRoles,
+ enabled = state.canChangePermission(permissionType),
onSelectOption = { role ->
state.eventSink(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
@@ -91,33 +92,28 @@ fun ChangeRoomPermissionsView(
AsyncActionView(
async = state.saveAction,
- onSuccess = { onComplete(true) },
- onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
- )
-
- AsyncActionView(
- async = state.confirmExitAction,
- onSuccess = { onComplete(false) },
- confirmationDialog = {
- ConfirmationDialog(
- title = stringResource(R.string.screen_room_change_role_unsaved_changes_title),
- content = stringResource(R.string.screen_room_change_role_unsaved_changes_description),
- submitText = stringResource(CommonStrings.action_save),
- cancelText = stringResource(CommonStrings.action_discard),
- onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
- onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }
- )
+ onSuccess = { onComplete(it) },
+ confirmationDialog = { confirming ->
+ when (confirming) {
+ is AsyncAction.ConfirmingCancellation -> {
+ SaveChangesDialog(
+ onSaveClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
+ onDiscardClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) },
+ onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) },
+ )
+ }
+ }
},
- onErrorDismiss = {},
+ onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
)
}
@Composable
private fun titleForSection(section: RoomPermissionsSection): String = when (section) {
- RoomPermissionsSection.SpaceDetails -> stringResource(R.string.screen_room_roles_and_permissions_space_details)
- RoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_roles_and_permissions_room_details)
- RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_roles_and_permissions_messages_and_content)
- RoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_roles_and_permissions_member_moderation)
+ RoomPermissionsSection.EditDetails -> stringResource(R.string.screen_room_change_permissions_room_details)
+ RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_change_permissions_messages_and_content)
+ RoomPermissionsSection.ManageMembers -> stringResource(R.string.screen_room_change_permissions_member_moderation)
+ RoomPermissionsSection.ManageSpace -> stringResource(R.string.screen_room_change_permissions_manage_space)
}
@Composable
@@ -130,6 +126,7 @@ private fun titleForType(type: RoomPermissionType): String = when (type) {
RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name)
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms)
}
@PreviewsDayNight
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
index 88da850fcd..3989a76df3 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService
@@ -44,7 +45,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -79,18 +80,13 @@ class ChangeRolesPresenter(
val usersWithRole = produceState>(initialValue = persistentListOf()) {
// If the role is admin, we need to include the owners as well since they implicitly have admin role
val owners = if (role == RoomMember.Role.Admin) {
- combine(
- room.usersWithRole(RoomMember.Role.Owner(isCreator = true)),
- room.usersWithRole(RoomMember.Role.Owner(isCreator = false)),
- ) { creators, superAdmins ->
- creators + superAdmins
- }
+ room.usersWithRole { role -> role is RoomMember.Role.Owner }
} else {
- emptyFlow()
+ flowOf(persistentListOf())
}
combine(
owners,
- room.usersWithRole(role),
+ room.usersWithRole { it == role },
) { owners, users ->
owners + users
}.map { members -> members.map { it.toMatrixUser() } }
@@ -129,9 +125,10 @@ class ChangeRolesPresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean {
- val currentUserRole = roomInfo.roleOf(room.sessionId)
- val otherUserRole = roomInfo.roleOf(userId)
- return currentUserRole.powerLevel > otherUserRole.powerLevel
+ val currentUserPowerLevel = roomInfo.powerLevelOf(room.sessionId)
+ val otherUserPowerLevel = roomInfo.powerLevelOf(userId)
+ return currentUserPowerLevel > otherUserPowerLevel &&
+ currentUserPowerLevel >= role.powerLevel
}
fun handleEvent(event: ChangeRolesEvent) {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
index bab24d3f42..20abc70bb7 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
@@ -172,8 +172,9 @@ fun ChangeRolesView(
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
- onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
+ onSaveClick = { state.eventSink(ChangeRolesEvent.Save) },
+ onDiscardClick = { state.eventSink(ChangeRolesEvent.Exit) },
+ onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) },
)
}
is ConfirmingModifyingOwners -> {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
index 4469eb8f37..da69ee52a9 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
@@ -11,7 +11,6 @@ package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
-import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -20,14 +19,6 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.matrix.api.room.BaseRoom
-import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.ui.model.roleOf
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -35,7 +26,6 @@ class RolesAndPermissionsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val presenter: RolesAndPermissionsPresenter,
- private val room: BaseRoom,
) : Node(buildContext, plugins = plugins), RolesAndPermissionsNavigator {
interface Callback : Plugin, RolesAndPermissionsNavigator {
override fun openAdminList()
@@ -54,22 +44,6 @@ class RolesAndPermissionsNode(
}
}
- override fun onBuilt() {
- super.onBuilt()
-
- // If the user is not an admin anymore, exit this section since they won't have permissions to use it
- lifecycleScope.launch {
- room.roomInfoFlow
- .filter { info ->
- val role = info.roleOf(room.sessionId)
- role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner
- }
- .take(1)
- .onEach { navigateUp() }
- .collect()
- }
- }
-
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
index 2ade971a66..dd3a59b99e 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
@@ -22,14 +22,13 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.api.room.activeRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
+import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -43,33 +42,24 @@ class RolesAndPermissionsPresenter(
override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState()
- val roomMembers by room.membersStateFlow.collectAsState()
- // Get the list of active room members (joined or invited), in order to filter members present in the power
- // level state Event.
- val activeRoomMemberIds by remember {
- derivedStateOf {
- roomMembers.activeRoomMembers().map { it.userId }
- }
- }
val moderatorCount by remember {
- derivedStateOf {
- roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator)
- }
- }
+ room.userCountWithRole { role -> role is RoomMember.Role.Moderator }
+ }.collectAsState(null)
+
val adminCount by remember {
+ room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner }
+ }.collectAsState(null)
+
+ val availableDemoteActions by remember {
derivedStateOf {
- val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin)
- val ownersCount = if (roomInfo.privilegedCreatorRole) {
- val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false))
- val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true))
- superAdmins + creators
- } else {
- 0
+ val currentRole = roomInfo.roleOf(room.sessionId)
+ when (currentRole) {
+ is RoomMember.Role.Admin -> persistentListOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember)
+ is RoomMember.Role.Moderator -> persistentListOf(SelfDemoteAction.ToMember)
+ else -> persistentListOf()
}
- admins + ownersCount
}
}
- val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
val changeOwnRoleAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val resetPermissionsAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
@@ -98,7 +88,7 @@ class RolesAndPermissionsPresenter(
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
adminCount = adminCount,
moderatorCount = moderatorCount,
- canDemoteSelf = canDemoteSelf.value,
+ availableSelfDemoteActions = availableDemoteActions,
changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value,
eventSink = ::handleEvent,
@@ -122,8 +112,4 @@ class RolesAndPermissionsPresenter(
room.resetPowerLevels()
}
}
-
- private fun RoomInfo.userCountWithRole(userIds: List, role: RoomMember.Role): Int {
- return usersWithRole(role).filter { it in userIds }.size
- }
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
index 3fc94f99e9..626ad3b699 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
@@ -8,14 +8,24 @@
package io.element.android.features.rolesandpermissions.impl.root
+import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.room.RoomMember
+import kotlinx.collections.immutable.ImmutableList
data class RolesAndPermissionsState(
val roomSupportsOwnerRole: Boolean,
- val adminCount: Int,
- val moderatorCount: Int,
- val canDemoteSelf: Boolean,
+ val adminCount: Int?,
+ val moderatorCount: Int?,
+ val availableSelfDemoteActions: ImmutableList,
val changeOwnRoleAction: AsyncAction,
val resetPermissionsAction: AsyncAction,
val eventSink: (RolesAndPermissionsEvents) -> Unit,
-)
+) {
+ val canSelfDemote = availableSelfDemoteActions.isNotEmpty()
+}
+
+enum class SelfDemoteAction(val role: RoomMember.Role, val titleRes: Int) {
+ ToModerator(RoomMember.Role.Moderator, R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator),
+ ToMember(RoomMember.Role.User, R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
+}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
index 23448c0351..45bd72db19 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
+import kotlinx.collections.immutable.toImmutableList
class RolesAndPermissionsStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -46,7 +47,7 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider = listOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember),
changeOwnRoleAction: AsyncAction = AsyncAction.Uninitialized,
resetPermissionsAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
roomSupportsOwnerRole = roomSupportsOwners,
adminCount = adminCount,
- canDemoteSelf = canDemoteSelf,
+ availableSelfDemoteActions = availableSelfDemoteActions.toImmutableList(),
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
resetPermissionsAction = resetPermissionsAction,
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
index 189ad83a5c..269fdee664 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
@@ -39,8 +39,8 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
-import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
@Composable
fun RolesAndPermissionsView(
@@ -63,16 +63,20 @@ fun RolesAndPermissionsView(
ListItem(
headlineContent = { Text(adminsTitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
- trailingContent = ListItemContent.Text("${state.adminCount}"),
+ trailingContent = state.adminCount?.let { adminCount ->
+ ListItemContent.Text("$adminCount")
+ },
onClick = { rolesAndPermissionsNavigator.openAdminList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
- trailingContent = ListItemContent.Text("${state.moderatorCount}"),
+ trailingContent = state.moderatorCount?.let { moderationCount ->
+ ListItemContent.Text("$moderationCount")
+ },
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
)
- if (state.canDemoteSelf) {
+ if (state.canSelfDemote) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
@@ -113,6 +117,7 @@ fun RolesAndPermissionsView(
when (state.changeOwnRoleAction) {
is AsyncAction.Confirming -> {
ChangeOwnRoleBottomSheet(
+ availableDemoteActions = state.availableSelfDemoteActions,
eventSink = state.eventSink,
)
}
@@ -132,6 +137,7 @@ fun RolesAndPermissionsView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChangeOwnRoleBottomSheet(
+ availableDemoteActions: ImmutableList,
eventSink: (RolesAndPermissionsEvents) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
@@ -160,24 +166,17 @@ private fun ChangeOwnRoleBottomSheet(
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
- onClick = {
- sheetState.hide(coroutineScope) {
- eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
- }
- },
- style = ListItemStyle.Destructive,
- )
- ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
- onClick = {
- sheetState.hide(coroutineScope) {
- eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
- }
- },
- style = ListItemStyle.Destructive,
- )
+ for (demoteAction in availableDemoteActions) {
+ ListItem(
+ headlineContent = { Text(stringResource(demoteAction.titleRes)) },
+ onClick = {
+ sheetState.hide(coroutineScope) {
+ eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role))
+ }
+ },
+ style = ListItemStyle.Destructive,
+ )
+ }
ListItem(
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
onClick = ::dismiss,
diff --git a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
index 3df8e0afbb..87936de3a7 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
@@ -2,9 +2,12 @@
"Správce"
"Vykázat lidi"
+ "Změnit nastavení"
"Odstranit zprávy"
"Člen"
"Pozvat přátele"
+ "Správa prostoru"
+ "Spravovat místnosti"
"Spravovat členy"
"Zprávy a obsah"
"Moderátor"
@@ -14,6 +17,7 @@
"Změnit název místnosti"
"Změnit téma místnosti"
"Odeslat zprávy"
+ "Oprávnění"
"Upravit správce"
"Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy."
"Přidat správce?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
index 4a0b5d179d..31aee3436e 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
@@ -1,17 +1,23 @@
- "Kun admins"
+ "Administrator"
"Spær brugere"
+ "Skift indstillinger"
"Fjern beskeder"
- "Invitér personer og acceptér anmodninger om at deltage"
+ "Medlem"
+ "Invitér andre"
+ "Administrér gruppe"
+ "Administrer rum"
+ "Administrer medlemmer"
"Beskeder og indhold"
- "Admins og moderatorer"
- "Fjern personer og afvis anmodninger om at deltage"
+ "Moderator"
+ "Fjern personer"
"Skift rummets avatar"
- "Rediger rum"
+ "Redigér detaljer"
"Skift rummets navn"
"Skift emne for rummet"
"Send beskeder"
+ "Tilladelser"
"Redigér admins"
"Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig."
"Tilføj Admin?"
@@ -32,6 +38,12 @@
"Du har ændringer, der ikke er gemt."
"Gem ændringer?"
"Der er ingen spærrede brugere i dette rum."
+
+ - "%1$d Spærret"
+ - "%1$d Spærret"
+
+ "Tjek stavningen eller prøv en ny søgning"
+ "Ingen resultater for \"%1$s\""
- "%1$d person"
- "%1$d personer"
@@ -43,8 +55,13 @@
"Fjern brugerens spærring fra rummet"
"Spærret"
"Medlemmer"
- "Kun admins"
- "Admins og moderatorer"
+
+ - "%1$d Inviteret"
+ - "%1$d Inviteret"
+
+ "Afventer"
+ "Administrator"
+ "Moderator"
"Ejeren"
"Medlemmer af rummet"
"Ophæver spærring af %1$s"
@@ -57,10 +74,12 @@
"Beskeder og indhold"
"Moderatorer"
"Ejere"
+ "Tilladelser"
"Nulstil tilladelser"
"Når du nulstiller tilladelserne, mister du de nuværende indstillinger."
"Nulstil tilladelser?"
"Roller"
"Detaljer om rummet"
+ "Detaljer om gruppe"
"Roller og tilladelser"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
index 2767781d2b..f51f96672b 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
@@ -1,17 +1,23 @@
- "Nur Admins"
+ "Admin"
"Mitglieder sperren"
+ "Einstellungen ändern"
"Nachrichten entfernen"
- "Personen einladen und Beitrittsanfragen annehmen"
+ "Mitglied"
+ "Mitglieder hinzufügen"
+ "Space konfigurieren"
+ "Chats und Gruppen konfigurieren"
+ "Mitglieder verwalten"
"Nachrichten senden & löschen"
- "Admins und Moderatoren"
- "Personen entfernen und Beitrittsanfragen ablehnen"
+ "Moderator"
+ "Mitglieder entfernen"
"Avatar ändern"
"Chat bearbeiten"
"Chat-Namen ändern"
"Chat Thema ändern"
"Nachrichten senden"
+ "Berechtigungen"
"Admins bearbeiten"
"Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast."
"Als Admin hinzufügen?"
@@ -32,6 +38,12 @@
"Du hast nicht gespeicherte Änderungen."
"Änderungen speichern?"
"Es gibt keine gesperrten Nutzer."
+
+ - "%1$d gesperrt"
+ - "%1$d gesperrt"
+
+ "Überprüfe die Schreibweise oder versuch\'s mit einer neuen Suche"
+ "Keine Ergebnisse für „%1$s“"
- "%1$d Person"
- "%1$d Personen"
@@ -43,8 +55,13 @@
"Sperre für diesen Chat aufheben"
"Gesperrt"
"Mitglieder"
- "Nur Admins"
- "Admins und Moderatoren"
+
+ - "%1$d eingeladen"
+ - "%1$d eingeladen"
+
+ "Ausstehend"
+ "Admin"
+ "Moderator"
"Eigentümer"
"Mitglieder"
"%1$s wird entsperrt."
@@ -57,10 +74,12 @@
"Nachrichten senden & löschen"
"Moderatoren"
"Eigentümer"
- "Rollen und Berechtigungen zurücksetzen"
+ "Berechtigungen"
+ "Berechtigungen zurücksetzen"
"Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen."
"Berechtigungen zurücksetzen?"
"Rollen"
"Chat-Details anpassen"
+ "Details zum Space"
"Rollen und Berechtigungen"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
index 7924e7ce7b..1c1f490580 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
@@ -2,9 +2,12 @@
"Peakasutajad"
"Suhtluskeelu seadmine"
+ "Muuda seadistusi"
"Eemalda sõnumid"
"Liikmed"
"Osalejate kutsumine"
+ "Halda kogukonda"
+ "Halda jututuba"
"Liikmete haldus"
"Sõnumid ja sisu"
"Moderaatorid"
@@ -14,6 +17,7 @@
"Jututoa nime muutmine"
"Jututoa teema muutmine"
"Sõnumite saatmine"
+ "Õigused"
"Muuda peakasutajaid"
"Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata."
"Lisame peakasutaja?"
@@ -34,6 +38,10 @@
"Sul on salvestamata muudatusi"
"Kas salvestame muudatused?"
"Suhtluskeeluga kasutajaid pole"
+
+ - "%1$d suhtluskeeluga kasutaja"
+ - "%1$d suhtluskeeluga kasutajat"
+
"Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti"
"Otsingul „%1$s“ pole tulemusi"
@@ -47,6 +55,10 @@
"Eemalda suhtluskeeld jututoas"
"Suhtluskeeluga kasutajad"
"Liikmed"
+
+ - "%1$d saatis kutse"
+ - "%1$d saatis kutse"
+
"Ootel"
"Peakasutajad"
"Moderaatorid"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
index 1322089dc8..29331bcd13 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
@@ -2,9 +2,12 @@
"Ylläpitäjä"
"Porttikieltojen antaminen"
+ "Asetusten muuttaminen"
"Viestien poistaminen"
"Jäsen"
"Ihmisten kutsuminen ja liittymispyyntöjen hyväksyminen"
+ "Tilan hallitseminen"
+ "Huoneiden hallitseminen"
"Jäsenien hallinta"
"Viestit ja sisältö"
"Valvoja"
@@ -14,6 +17,7 @@
"Huoneen nimen vaihtaminen"
"Huoneen aiheen vaihtaminen"
"Viestien lähettäminen"
+ "Oikeudet"
"Muokkaa ylläpitäjiä"
"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."
"Lisätäänkö ylläpitäjä?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
index f44072366d..874b6710cd 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
@@ -2,9 +2,12 @@
"Administrateurs"
"Bannir des participants"
+ "Changer les paramètres"
"Supprimer des messages"
"Membre"
"Inviter des personnes"
+ "Gérer l’espace"
+ "Gérer les salons"
"Gérer les membres"
"Messages et contenus"
"Modérateurs"
@@ -14,6 +17,7 @@
"Changer le nom du salon"
"Changer le sujet du salon"
"Envoyer des messages"
+ "Autorisations"
"Modifier les administrateurs"
"Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir l’utilisateur pour qu’il ait le même niveau que vous."
"Ajouter un administrateur ?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..7f24535124
--- /dev/null
+++ b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,88 @@
+
+
+ "Administrator"
+ "Zabrana pristupa osobama"
+ "Promijeni postavke"
+ "Uklanjanje poruka"
+ "Član"
+ "Pozivanje osoba"
+ "Upravljaj prostorom"
+ "Upravljaj sobama"
+ "Upravljanje članovima"
+ "Poruke i sadržaj"
+ "Moderator"
+ "Uklanjanje osoba"
+ "Promjena avatara"
+ "Uredi pojedinosti"
+ "Promjena imena"
+ "Promjena teme"
+ "Slanje poruka"
+ "Dopuštenja"
+ "Uredi administratore"
+ "Nećete moći poništiti ovu radnju. Postavit ćete da korisnik ima isti položaj kao i vi."
+ "Dodati administratora?"
+ "Nećete moći poništiti ovu radnju. Prenosite vlasništvo na odabrane korisnike. Nakon što odete, to će biti trajno."
+ "Želite li prenijeti vlasništvo?"
+ "Degradiraj"
+ "Nećete moći poništiti ovu promjenu jer sami sebe degradirate. Ako ste posljednji privilegirani korisnik u sobi, nećete moći ponovno dobiti privilegije."
+ "Želite li se degradirati?"
+ "%1$s (na čekanju)"
+ "(na čekanju)"
+ "Administratori automatski imaju moderatorske ovlasti"
+ "Vlasnici automatski imaju administratorske ovlasti."
+ "Uredi moderatore"
+ "Odaberi vlasnike"
+ "Administratori"
+ "Moderatori"
+ "Članovi"
+ "Niste spremili sve promjene."
+ "Želite li spremiti promjene?"
+ "Nema zabranjenih korisnika."
+
+ - "%1$d zabranjen"
+ - "%1$d zabranjena"
+ - "%1$d zabranjenih"
+
+ "Provjerite pravopis ili pokušajte s novim pretraživanjem"
+ "Nema rezultata za “%1$s”"
+
+ - "%1$d osoba"
+ - "%1$d osobe"
+ - "%1$d ljudi"
+
+ "Zabrani korisnika"
+ "Samo ukloni člana"
+ "Poništi zabranu"
+ "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani."
+ "Poništi zabranu pristupa korisniku"
+ "Zabranjeni"
+ "Članovi"
+
+ - "%1$d pozvan"
+ - "%1$d pozvana"
+ - "%1$d pozvanih"
+
+ "Na čekanju"
+ "Administrator"
+ "Moderator"
+ "Vlasnik"
+ "Članovi sobe"
+ "Uklanja se zabrana korisniku %1$s"
+ "Administratori"
+ "Administratori i vlasnici"
+ "Promijeni moju ulogu"
+ "Degradiraj u člana"
+ "Degradiraj u moderatora"
+ "Moderiranje članova"
+ "Poruke i sadržaj"
+ "Moderatori"
+ "Vlasnici"
+ "Dopuštenja"
+ "Poništi dopuštenja"
+ "Nakon što poništite dopuštenja, izgubit ćete trenutačne postavke."
+ "Želite li poništiti dopuštenja?"
+ "Uloge"
+ "Pojedinosti o sobi"
+ "Pojedinosti o prostoru"
+ "Uloge i dopuštenja"
+
diff --git a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
index 77e6e3b389..4160039a0a 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
@@ -2,9 +2,12 @@
"Adminisztrátor"
"Emberek kitiltása"
+ "Beállítások módosítása"
"Üzenetek eltávolítása"
"Tag"
"Emberek meghívása"
+ "Tér kezelése"
+ "Szobák kezelése"
"Tagok kezelése"
"Üzenetek és tartalom"
"Moderátor"
@@ -14,6 +17,7 @@
"Szoba nevének módosítása"
"Szoba témájának módosítása"
"Üzenetek küldése"
+ "Jogosultságok"
"Adminisztrátorok szerkesztése"
"Ezt a műveletet nem fogja tudja visszavonni. Ugyanarra a szintre lépteti elő a felhasználót, mint amellyel Ön is rendelkezik."
"Adminisztrátor hozzáadása?"
@@ -34,6 +38,8 @@
"Mentetlen módosításai vannak."
"Menti a módosításokat?"
"Ebben a szobában nincsenek kitiltott felhasználók."
+ "Ellenőrizze a helyesírást, vagy próbáljon meg egy új keresést"
+ "Nincs találat a következőre: „%1$s\""
- "%1$d személy"
- "%1$d személy"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
index 2b439a6c61..b1dea12151 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
@@ -2,9 +2,12 @@
"Amministratore"
"Escludi membri"
+ "Modifica impostazioni"
"Rimuovi messaggi"
"Membro"
"Invita persone"
+ "Gestire lo spazio"
+ "Gestisci le stanze"
"Gestisci membri"
"Messaggi e contenuti"
"Moderatore"
@@ -14,6 +17,7 @@
"Cambia il nome della stanza"
"Cambiare l\'argomento della stanza"
"Inviare messaggi"
+ "Autorizzazioni"
"Modifica amministratori"
"Non potrai annullare questa azione. Stai promuovendo l\'utente al tuo stesso livello di potere."
"Aggiungi amministratore?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
index 65664c187a..41f475012f 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
@@ -1,14 +1,16 @@
- "Kun for administratorer"
+ "Admin"
"Forby folk"
"Fjern meldinger"
- "Inviter folk og godta forespørsler om å bli med"
+ "Medlem"
+ "Inviter folk"
+ "Administrer medlemmer"
"Meldinger og innhold"
- "Administratorer og moderatorer"
- "Fjern folk og avslå forespørsler om å bli med"
+ "Moderator"
+ "Fjern folk"
"Endre romavatar"
- "Rediger rom"
+ "Rediger detaljer"
"Endre romnavn"
"Endre temaet til rommet"
"Send meldinger"
@@ -31,7 +33,7 @@
"Medlemmer"
"Du har endringer som ikke er lagret."
"Lagre endringer?"
- "Det er ingen utestengte brukere i dette rommet."
+ "Det er ingen utestengte brukere."
- "%1$d person"
- "%1$d personer"
@@ -43,8 +45,8 @@
"Fjern utestengelsen fra rommet"
"Utestengt"
"Medlemmer"
- "Kun for administratorer"
- "Administratorer og moderatorer"
+ "Admin"
+ "Moderator"
"Eier"
"Medlemmer av rommet"
"Oppheve utestengelsen av %1$s"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
index 886d3c541d..43fa8d83f2 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
@@ -2,9 +2,12 @@
"Administradores"
"Banir pessoas"
+ "Alterar configurações"
"Remover mensagens"
"Membro"
"Convidar pessoas"
+ "Gerenciar espaço"
+ "Gerenciar salas"
"Gerenciar membros"
"Mensagens e conteúdo"
"Moderador"
@@ -14,6 +17,7 @@
"Alterar nome da sala"
"Alterar tópico da sala"
"Enviar mensagens"
+ "Permissões"
"Editar administradores"
"Você não poderá desfazer essa ação. Você está promovendo o usuário a ter o mesmo nível de poder que você."
"Adicionar administrador?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
index c2b248f264..ac9ec710a2 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
@@ -1,17 +1,23 @@
- "Doar administratori"
+ "Administrator"
"Interziceți persoane"
+ "Modificați setările"
"Ștergeți mesajele"
- "Invitați persoane și acceptați cereri de alaturare"
+ "Membru"
+ "Invitați persoane"
+ "Gestionați spațiul"
+ "Gestionați camerele"
+ "Gestionați membrii"
"Mesaje și conținut"
- "Administratori și moderatori"
- "Îndepărtați persoane și refuzați cereri de alăturare"
+ "Moderator"
+ "Îndepărtați persoane"
"Schimbați avatarul camerei"
- "Editați camera"
+ "Editați detaliile"
"Schimbă numele camerei"
"Schimbați subiectul camerei"
"Trimiteți mesaje"
+ "Permisiuni"
"Editați administratorii"
"Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune."
"Adăugați administrator?"
@@ -31,9 +37,17 @@
"Membri"
"Aveți modificări nesalvate."
"Salvați modificările?"
- "Nu există utilizatori interziși în această cameră."
+ "Nu există utilizatori interziși."
+
+ - "%1$d Interzis"
+ - "%1$d Interziși"
+ - "%1$d Interziși"
+
+ "Verificați ortografia sau încercați o căutare nouă"
+ "Niciun rezultat pentru “%1$s”"
- - "o persoană"
+ - "%1$d persoană"
+ - "%1$d persoane"
- "%1$d persoane"
"Îndepărtați și interziceți membrul"
@@ -43,8 +57,14 @@
"Revocati excluderea din camera"
"Excluși"
"Membri"
- "Doar administratori"
- "Administratori și moderatori"
+
+ - "%1$d Invitat"
+ - "%1$d Invitați"
+ - "%1$d Invitați"
+
+ "În așteptare"
+ "Administrator"
+ "Moderator"
"Proprietar"
"Membrii camerei"
"Se anulează interzicerea lui %1$s"
@@ -57,10 +77,12 @@
"Mesaje și conținut"
"Moderatori"
"Proprietari"
+ "Permisiuni"
"Resetați permisiunile"
"După ce resetați permisiunile, veți pierde setările curente."
"Resetați permisiunile?"
"Roluri"
"Detaliile camerei"
+ "Detalii spațiu"
"Roluri și permisiuni"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
index f54b984ea3..d922e58550 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
@@ -2,9 +2,12 @@
"Только администраторы"
"Блокировать людей могут"
+ "Изменить настройки"
"Удалить сообщения"
"Участник"
"Пригласить людей"
+ "Управление пространством"
+ "Управление комнатами"
"Список участников"
"Сообщения и содержание"
"Модератор"
@@ -14,6 +17,7 @@
"Менять название комнаты могут"
"Менять тему комнаты могут"
"Отправлять сообщения могут"
+ "Разрешения"
"Редактировать роль администраторов"
"Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему."
"Добавить администратора?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
index a707b48bc5..d159fb03f9 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
@@ -1,17 +1,23 @@
- "Iba správcovia"
+ "Správca"
"Zakázať ľudí"
+ "Zmeniť nastavenia"
"Odstrániť správy"
- "Pozvite ľudí a prijmite žiadosti o pripojenie"
+ "Člen"
+ "Pozvať ľudí"
+ "Spravovať priestor"
+ "Spravovať miestnosti"
+ "Spravovať členov"
"Správy a obsah"
- "Správcovia a moderátori"
- "Odstrániť ľudí a odmietnuť žiadosti o pripojenie"
+ "Moderátor"
+ "Odstrániť ľudí"
"Zmeniť obrázok miestnosti"
- "Upraviť miestnosť"
+ "Upraviť podrobnosti"
"Zmeniť názov miestnosti"
"Zmeniť tému miestnosti"
"Odoslať správy"
+ "Povolenia"
"Upraviť správcov"
"Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy."
"Pridať správcu?"
@@ -32,6 +38,13 @@
"Máte neuložené zmeny."
"Uložiť zmeny?"
"Neexistujú žiadni zablokovaní používatelia."
+
+ - "%1$d zakázaný"
+ - "%1$d zakázaní"
+ - "%1$d zakázaných"
+
+ "Skontrolujte preklepy alebo skúste nové vyhľadávanie"
+ "Žiadne výsledky pre „%1$s“"
- "%1$d osoba"
- "%1$d osoby"
@@ -44,8 +57,14 @@
"Zrušiť zákaz prístupu do miestnosti"
"Zakázaní"
"Členovia"
- "Iba správcovia"
- "Správcovia a moderátori"
+
+ - "%1$d pozvaný"
+ - "%1$d pozvaní"
+ - "%1$d pozvaných"
+
+ "Čaká na schválenie"
+ "Správca"
+ "Moderátor"
"Vlastník"
"Členovia miestnosti"
"Zrušenie zákazu %1$s"
@@ -58,6 +77,7 @@
"Správy a obsah"
"Moderátori"
"Vlastníci"
+ "Povolenia"
"Obnoviť povolenia"
"Po obnovení oprávnení prídete o aktuálne nastavenia."
"Obnoviť oprávnenia?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml
index f56210a29a..03f94969b5 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml
@@ -4,11 +4,12 @@
"Odamlarni taqiqlash"
"Xabarlarni olib tashlash"
"Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling"
+ "A’zolarni boshqarish"
"Xabarlar va kontent"
"Adminlar va moderatorlar"
"Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish"
"Xona avatarini oʻzgartirish"
- "Xonani tahrirlash"
+ "Tafsilotlarni tahrirlash"
"Xona nomini oʻzgartirish"
"Xona mavzusini almashtirish"
"Xabarlar yuborish"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml
index 50555bf9ed..7bc662e2fc 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml
@@ -2,9 +2,12 @@
"管理員"
"管理黑名單"
+ "變更設定"
"移除訊息"
"成員"
"邀請夥伴"
+ "管理空間"
+ "管理聊天室"
"管理成員"
"訊息與內容"
"版主"
@@ -14,6 +17,7 @@
"變更聊天室名稱"
"變更聊天室主題"
"傳送訊息"
+ "權限"
"編輯管理員"
"您將無法復原此動作。您正將使用者提昇至與您相同的權力等級。"
"要新增管理員嗎?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values/localazy.xml b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml
index 70efa48e5a..e5ab3f1cd7 100644
--- a/features/rolesandpermissions/impl/src/main/res/values/localazy.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml
@@ -2,9 +2,12 @@
"Admin"
"Ban people"
+ "Change settings"
"Remove messages"
"Member"
"Invite people"
+ "Manage space"
+ "Manage rooms"
"Manage members"
"Messages and content"
"Moderator"
@@ -14,6 +17,7 @@
"Change name"
"Change topic"
"Send messages"
+ "Permissions"
"Edit Admins"
"You will not be able to undo this action. You are promoting the user to have the same power level as you."
"Add Admin?"
diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt
index 7c328c9bad..ba7d47adb2 100644
--- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt
+++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt
@@ -16,13 +16,18 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
+import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.services.analytics.test.FakeAnalyticsService
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -39,7 +44,6 @@ class ChangeRoomPermissionsPresenterTest {
assertThat(this.itemsBySection).isNotEmpty()
assertThat(this.hasChanges).isFalse()
assertThat(this.saveAction).isEqualTo(AsyncAction.Uninitialized)
- assertThat(this.confirmExitAction).isEqualTo(AsyncAction.Uninitialized)
}
// Updated state, permissions loaded
@@ -54,7 +58,7 @@ class ChangeRoomPermissionsPresenterTest {
presenter.present()
}.test {
val itemsBySection = awaitUpdatedItem().itemsBySection
- assertThat(itemsBySection[RoomPermissionsSection.RoomDetails]).containsExactly(
+ assertThat(itemsBySection[RoomPermissionsSection.EditDetails]).containsExactly(
RoomPermissionType.ROOM_NAME,
RoomPermissionType.ROOM_AVATAR,
RoomPermissionType.ROOM_TOPIC,
@@ -63,7 +67,7 @@ class ChangeRoomPermissionsPresenterTest {
RoomPermissionType.SEND_EVENTS,
RoomPermissionType.REDACT_EVENTS,
)
- assertThat(itemsBySection[RoomPermissionsSection.MembershipModeration]).containsExactly(
+ assertThat(itemsBySection[RoomPermissionsSection.ManageMembers]).containsExactly(
RoomPermissionType.INVITE,
RoomPermissionType.KICK,
RoomPermissionType.BAN,
@@ -71,6 +75,28 @@ class ChangeRoomPermissionsPresenterTest {
}
}
+ @Test
+ fun `present - check canChangePermissions and selectableOptions for moderator`() = runTest {
+ val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ initialRoomInfo = initialRoomInfo(role = Moderator),
+ powerLevelsResult = { Result.success(defaultPermissions()) }
+ ),
+ )
+ val presenter = createChangeRoomPermissionsPresenter(room = room)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val state = awaitUpdatedItem()
+ assertThat(state.selectableRoles).containsExactly(SelectableRole.Moderator, SelectableRole.Everyone)
+ for (sectionItems in state.itemsBySection.values) {
+ for (permissionType in sectionItems) {
+ assertThat(state.canChangePermission(permissionType)).isTrue()
+ }
+ }
+ }
+ }
+
@Test
fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest {
val presenter = createChangeRoomPermissionsPresenter()
@@ -78,13 +104,13 @@ class ChangeRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
- assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
+ assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(state.hasChanges).isFalse()
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin))
awaitItem().run {
- assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
+ assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(hasChanges).isTrue()
}
}
@@ -116,8 +142,9 @@ class ChangeRoomPermissionsPresenterTest {
invite = Moderator.powerLevel,
kick = Moderator.powerLevel,
ban = Moderator.powerLevel,
+ stateDefault = Moderator.powerLevel,
redactEvents = Moderator.powerLevel,
- sendEvents = Moderator.powerLevel,
+ eventsDefault = Moderator.powerLevel,
roomName = Moderator.powerLevel,
roomAvatar = Moderator.powerLevel,
roomTopic = Moderator.powerLevel,
@@ -142,14 +169,14 @@ class ChangeRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
- assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
+ assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(state.hasChanges).isFalse()
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator))
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator))
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator))
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Everyone))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Admin))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Admin))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Admin))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Admin))
@@ -161,16 +188,16 @@ class ChangeRoomPermissionsPresenterTest {
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().hasChanges).isFalse()
awaitItem().run {
- assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
- assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
+ assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
+ assertThat(saveAction).isEqualTo(AsyncAction.Success(true))
}
assertThat(analyticsService.capturedEvents).containsExactlyElementsIn(
listOf(
- RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Moderator),
- RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Moderator),
- RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Moderator),
- RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Moderator),
- RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.User),
+ RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Administrator),
+ RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Administrator),
+ RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Administrator),
+ RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Administrator),
+ RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.Administrator),
RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, RoomModeration.Role.Administrator),
RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, RoomModeration.Role.Administrator),
RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, RoomModeration.Role.Administrator),
@@ -207,17 +234,17 @@ class ChangeRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
- assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
+ assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(state.hasChanges).isFalse()
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Save)
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
awaitItem().run {
- assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
+ assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
// Couldn't save the changes, so they're still pending
assertThat(hasChanges).isTrue()
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -225,7 +252,7 @@ class ChangeRoomPermissionsPresenterTest {
state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions)
awaitItem().run {
- assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
+ assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(hasChanges).isTrue()
}
@@ -239,14 +266,14 @@ class ChangeRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator))
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Exit)
- assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.ConfirmingNoParams)
+ assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
state.eventSink(ChangeRoomPermissionsEvent.Exit)
- assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit))
+ assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(false))
}
}
@@ -260,13 +287,16 @@ class ChangeRoomPermissionsPresenterTest {
state.eventSink(ChangeRoomPermissionsEvent.Exit)
- assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit))
+ assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(false))
}
}
private fun createChangeRoomPermissionsPresenter(
room: FakeJoinedRoom = FakeJoinedRoom(
- baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }),
+ baseRoom = FakeBaseRoom(
+ initialRoomInfo = initialRoomInfo(),
+ powerLevelsResult = { Result.success(defaultPermissions()) }
+ ),
),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = ChangeRoomPermissionsPresenter(
@@ -274,6 +304,13 @@ class ChangeRoomPermissionsPresenterTest {
analyticsService = analyticsService,
)
+ private fun initialRoomInfo(role: RoomMember.Role = Admin) = aRoomInfo(
+ roomPowerLevels = RoomPowerLevels(
+ values = defaultPermissions(),
+ users = persistentMapOf(A_SESSION_ID to role.powerLevel),
+ )
+ )
+
private fun defaultPermissions() = defaultRoomPowerLevelValues()
private suspend fun TurbineTestContext.awaitUpdatedItem(): ChangeRoomPermissionsState {
diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt
index 915359cfcf..f28c9c150f 100644
--- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt
+++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt
@@ -18,7 +18,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
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.clickOnFirst
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
@@ -76,7 +75,7 @@ class ChangeRoomPermissionsViewTest {
rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState(
hasChanges = true,
- confirmExitAction = AsyncAction.ConfirmingNoParams,
+ saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder,
),
)
@@ -90,11 +89,11 @@ class ChangeRoomPermissionsViewTest {
rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState(
hasChanges = true,
- confirmExitAction = AsyncAction.ConfirmingNoParams,
+ saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder,
),
)
- rule.clickOnFirst(CommonStrings.action_save)
+ rule.clickOn(CommonStrings.action_save, inDialog = true)
recorder.assertSingle(ChangeRoomPermissionsEvent.Save)
}
@@ -105,7 +104,7 @@ class ChangeRoomPermissionsViewTest {
state = aChangeRoomPermissionsState(
itemsBySection = persistentMapOf(
// Makes sure there is only one item to click on
- RoomPermissionsSection.RoomDetails to persistentListOf(RoomPermissionType.ROOM_NAME)
+ RoomPermissionsSection.EditDetails to persistentListOf(RoomPermissionType.ROOM_NAME)
),
eventSink = recorder,
)
@@ -136,9 +135,23 @@ class ChangeRoomPermissionsViewTest {
rule.setChangeRoomPermissionsRule(
state = aChangeRoomPermissionsState(
hasChanges = true,
- saveAction = AsyncAction.Success(Unit),
+ saveAction = AsyncAction.Success(true),
),
- onComplete = callback
+ onComplete = callback,
+ )
+ rule.clickOn(CommonStrings.action_save)
+ }
+ }
+
+ @Test
+ fun `a cancellation exits the screen`() {
+ ensureCalledOnceWithParam(false) { callback ->
+ rule.setChangeRoomPermissionsRule(
+ state = aChangeRoomPermissionsState(
+ hasChanges = true,
+ saveAction = AsyncAction.Success(false),
+ ),
+ onComplete = callback,
)
rule.clickOn(CommonStrings.action_save)
}
diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt
index 1da7ddcce4..8747585354 100644
--- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt
+++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt
@@ -18,7 +18,9 @@ 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.RoomMembersState
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
+import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
@@ -26,11 +28,13 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.anAlice
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
@@ -63,7 +67,7 @@ class ChangeRolesPresenterTest {
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
- skipItems(1)
+ skipItems(2)
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
}
}
@@ -161,13 +165,13 @@ class ChangeRolesPresenterTest {
}
@Test
- fun `present - when modifying admins, creators are displayed too`() = runTest {
+ fun `present - when modifying admins, creators are displayed too - privilegedCreatorRole is true`() = runTest {
val room = FakeJoinedRoom().apply {
val creatorUserId = UserId("@creator:matrix.org")
val memberList = aRoomMemberList()
.plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId))
.toImmutableList()
- givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId)))
+ givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId), privilegedCreatorRole = true))
givenRoomMembersState(RoomMembersState.Ready(memberList))
}
val presenter = createChangeRolesPresenter(room = room)
@@ -190,6 +194,7 @@ class ChangeRolesPresenterTest {
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
+ skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
@@ -207,6 +212,7 @@ class ChangeRolesPresenterTest {
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
+ skipItems(1)
val initialState = awaitItem()
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8)
@@ -231,7 +237,7 @@ class ChangeRolesPresenterTest {
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
- skipItems(1)
+ skipItems(2)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8)
assertThat(initialResults?.moderators).hasSize(1)
@@ -250,44 +256,48 @@ class ChangeRolesPresenterTest {
@Test
fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(room = room)
+ val userMember = roomMemberList.first { it.role == RoomMember.Role.User }
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
assertThat(awaitItem().selectedUsers).hasSize(2)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
assertThat(awaitItem().selectedUsers).hasSize(1)
}
}
@Test
fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(room = room)
+ val userMember = roomMemberList.first { it.role == RoomMember.Role.User }
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
with(awaitItem()) {
assertThat(selectedUsers).hasSize(2)
assertThat(hasPendingChanges).isTrue()
}
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
with(awaitItem()) {
assertThat(selectedUsers).hasSize(1)
assertThat(hasPendingChanges).isFalse()
@@ -297,9 +307,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Exit will display success false if no pending changes`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
@@ -315,9 +326,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - CloseDialog will remove exit confirmation`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
@@ -339,9 +351,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(room = room)
presenter.test {
@@ -365,12 +378,13 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save will display a confirmation when adding admins`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
presenter.test {
@@ -389,9 +403,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - CloseDialog will remove the confirmation dialog`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom().apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
presenter.test {
@@ -413,25 +428,27 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save will just save the data for moderators`() = runTest {
val analyticsService = FakeAnalyticsService()
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Moderator,
room = room,
analyticsService = analyticsService
)
+ val userMember = roomMemberList.first { it.role == RoomMember.Role.User }
presenter.test {
- skipItems(1)
+ skipItems(2)
val initialState = awaitItem()
- assertThat(initialState.selectedUsers).isEmpty()
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ assertThat(initialState.selectedUsers).hasSize(1)
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
awaitItem().also {
- assertThat(it.selectedUsers).hasSize(1)
+ assertThat(it.selectedUsers).hasSize(2)
it.eventSink(ChangeRolesEvent.Save)
}
assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java)
@@ -478,17 +495,14 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest {
val analyticsService = FakeAnalyticsService()
+ val alice = anAlice()
+ val me = aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = A_SESSION_ID)
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(
- aRoomInfo(
- roomCreators = listOf(sessionId),
- roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2)
- )
- )
+ val roomMemberList = persistentListOf(alice, me)
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Admin,
@@ -499,7 +513,7 @@ class ChangeRolesPresenterTest {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(2)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(alice.toMatrixUser()))
awaitItem().also {
assertThat(it.selectedUsers).hasSize(1)
it.eventSink(ChangeRolesEvent.Save)
@@ -513,20 +527,22 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save can handle failures and CloseDialog clears them`() = runTest {
+ val roomMemberList = aRoomMemberList()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
).apply {
- givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
- givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID)))
+ givenRoomMembersState(RoomMembersState.Ready(roomMemberList))
+ givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room)
+ val userMember = roomMemberList.first { it.role == RoomMember.Role.User }
presenter.test {
- skipItems(1)
+ skipItems(2)
val initialState = awaitItem()
- assertThat(initialState.selectedUsers).isEmpty()
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
+ assertThat(initialState.selectedUsers).hasSize(1)
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser()))
awaitItem().also {
- assertThat(it.selectedUsers).hasSize(1)
+ assertThat(it.selectedUsers).hasSize(2)
it.eventSink(ChangeRolesEvent.Save)
}
val loadingState = awaitItem()
@@ -550,13 +566,12 @@ class ChangeRolesPresenterTest {
}
}
- private fun roomPowerLevelsWithRole(
- role: RoomMember.Role,
- userId: UserId = A_USER_ID,
+ private fun roomPowerLevelsFromRoomMemberList(
+ roomMemberList: List,
): RoomPowerLevels {
return RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
- users = persistentMapOf(userId to role.powerLevel)
+ users = roomMemberList.associate { it.userId to it.role.powerLevel }.toImmutableMap()
)
}
diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt
index fd45e5408c..39967f9160 100644
--- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt
+++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt
@@ -119,7 +119,7 @@ class ChangeRolesViewTest {
}
@Test
- fun `exit confirmation dialog - submit exits the screen`() {
+ fun `exit confirmation dialog - discard exits the screen`() {
val eventsRecorder = EventsRecorder()
rule.setChangeRolesContent(
state = aChangeRolesState(
@@ -128,12 +128,12 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ rule.clickOn(CommonStrings.action_discard)
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
}
@Test
- fun `exit confirmation dialog - cancel removes the dialog`() {
+ fun `exit confirmation dialog - save emits the save event`() {
val eventsRecorder = EventsRecorder()
rule.setChangeRolesContent(
state = aChangeRolesState(
@@ -142,8 +142,8 @@ class ChangeRolesViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
+ rule.clickOn(CommonStrings.action_save)
+ eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@Test
diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt
index 3eaacb9c3b..54bd6b7987 100644
--- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt
+++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt
@@ -8,16 +8,17 @@
package io.element.android.features.rolesandpermissions.impl.root
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembersState
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -30,12 +31,10 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRolesAndPermissionsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
with(awaitItem()) {
- assertThat(adminCount).isEqualTo(0)
- assertThat(moderatorCount).isEqualTo(0)
+ assertThat(adminCount).isNull()
+ assertThat(moderatorCount).isNull()
assertThat(changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@@ -44,12 +43,9 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - ChangeOwnRole presents a confirmation dialog`() = runTest {
val presenter = createRolesAndPermissionsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole)
-
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.ConfirmingNoParams)
}
}
@@ -60,12 +56,11 @@ class RolesAndPermissionPresenterTest {
val presenter = createRolesAndPermissionsPresenter(
dispatchers = testCoroutineDispatchers(),
room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(updateMembersResult = {}),
updateUserRoleResult = { Result.success(Unit) }
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
@@ -81,12 +76,11 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - DemoteSelfTo can handle failures and clean them`() = runTest(StandardTestDispatcher()) {
val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(updateMembersResult = {}),
updateUserRoleResult = { Result.failure(Exception("Failed to update role")) }
)
val presenter = createRolesAndPermissionsPresenter(room = room, dispatchers = testCoroutineDispatchers())
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
@@ -104,9 +98,7 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - CancelPendingAction dismisses confirmation dialog too`() = runTest {
val presenter = createRolesAndPermissionsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole)
awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction)
@@ -121,12 +113,11 @@ class RolesAndPermissionPresenterTest {
val presenter = createRolesAndPermissionsPresenter(
analyticsService = analyticsService,
room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(updateMembersResult = {}),
resetPowerLevelsResult = { Result.success(Unit) }
)
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions)
// Confirmation
@@ -141,9 +132,7 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - ResetPermissions confirmation can be cancelled`() = runTest {
val presenter = createRolesAndPermissionsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions)
awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction)
@@ -152,8 +141,26 @@ class RolesAndPermissionPresenterTest {
}
}
+ @Test
+ fun `present - admins and moderator counts are updated when members changes`() = runTest {
+ val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(updateMembersResult = {}),
+ )
+ val presenter = createRolesAndPermissionsPresenter(room = room)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.adminCount).isNull()
+ assertThat(initialState.moderatorCount).isNull()
+ room.givenRoomMembersState(state = RoomMembersState.Ready(aRoomMemberList()))
+ skipItems(1)
+ val finalState = awaitItem()
+ assertThat(finalState.adminCount).isEqualTo(1)
+ assertThat(finalState.moderatorCount).isEqualTo(1)
+ }
+ }
+
private fun TestScope.createRolesAndPermissionsPresenter(
- room: FakeJoinedRoom = FakeJoinedRoom(),
+ room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService()
): RolesAndPermissionsPresenter {
diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt
index 01f2787778..62cf3285f3 100644
--- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt
+++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt
@@ -14,7 +14,7 @@ import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEn
import io.element.android.tests.testutils.lambda.lambdaError
class FakeRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint {
- override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
+ override fun createNode(parentNode: Node, buildContext: BuildContext, callback: RolesAndPermissionsEntryPoint.Callback): Node {
lambdaError()
}
}
diff --git a/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..2d1e42c405
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Nismo mogli prikazati pregled ove sobe"
+ "Nije uspjelo razrješavanje aliasa sobe."
+
diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt
index 5de47f9ff2..b18a2772a3 100644
--- a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt
+++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt
@@ -21,7 +21,8 @@ import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.ui.room.canCall
+import io.element.android.libraries.matrix.api.room.powerlevels.canCall
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
@Inject
class RoomCallStatePresenter(
@@ -35,8 +36,7 @@ class RoomCallStatePresenter(
value = sessionEnterpriseService.isElementCallAvailable()
}
val roomInfo by room.roomInfoFlow.collectAsState()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
- val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
+ val canJoinCall by room.permissionsAsState(false) { perms -> perms.canCall() }
val isUserInTheCall by remember {
derivedStateOf {
room.sessionId in roomInfo.activeRoomCallParticipants
diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt
index 1aceee227a..bdececf584 100644
--- a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt
+++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt
@@ -15,9 +15,12 @@ import io.element.android.features.call.test.FakeCurrentCallService
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -28,7 +31,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(false) },
+ roomPermissions = roomPermissions(false),
)
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
@@ -47,7 +50,7 @@ class RoomCallStatePresenterTest {
fun `present - element call not available`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(false) },
+ roomPermissions = roomPermissions(false),
)
)
val presenter = createRoomCallStatePresenter(
@@ -66,7 +69,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state - user can join call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(true) },
+ roomPermissions = roomPermissions(true),
)
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
@@ -85,7 +88,7 @@ class RoomCallStatePresenterTest {
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(false) },
+ roomPermissions = roomPermissions(false),
initialRoomInfo = aRoomInfo(hasRoomCall = true),
)
)
@@ -106,7 +109,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call on another session`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(true) },
+ roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@@ -133,7 +136,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call locally`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(true) },
+ roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@@ -163,7 +166,7 @@ class RoomCallStatePresenterTest {
fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserJoinCallResult = { Result.success(true) },
+ roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@@ -223,6 +226,17 @@ class RoomCallStatePresenterTest {
}
}
+ private fun roomPermissions(canJoinCall: Boolean): FakeRoomPermissions {
+ return FakeRoomPermissions(
+ canSendState = { stateEvent ->
+ when (stateEvent) {
+ StateEventType.CallMember -> canJoinCall
+ else -> lambdaError()
+ }
+ }
+ )
+ }
+
private fun createRoomCallStatePresenter(
joinedRoom: JoinedRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(),
diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts
index 4ca260be16..2765a95a1e 100644
--- a/features/roomdetails/impl/build.gradle.kts
+++ b/features/roomdetails/impl/build.gradle.kts
@@ -59,6 +59,7 @@ dependencies {
implementation(projects.features.roommembermoderation.api)
implementation(projects.features.rolesandpermissions.api)
implementation(projects.features.securityandprivacy.api)
+ implementation(projects.features.roomdetailsedit.api)
implementation(projects.features.invitepeople.api)
testCommonDependencies(libs, true)
@@ -73,6 +74,7 @@ dependencies {
testImplementation(projects.features.call.test)
testImplementation(projects.features.rolesandpermissions.test)
testImplementation(projects.features.securityandprivacy.test)
+ testImplementation(projects.features.roomdetailsedit.test)
testImplementation(projects.features.knockrequests.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.poll.test)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
index c8ef60513c..03adbded1b 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
@@ -35,11 +35,11 @@ import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRoles
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
-import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
+import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
@@ -85,6 +85,7 @@ class RoomDetailsFlowNode(
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint,
private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint,
+ private val roomDetailsEditEntryPoint: RoomDetailsEditEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
@@ -256,7 +257,7 @@ class RoomDetailsFlowNode(
}
NavTarget.RoomDetailsEdit -> {
- createNode(buildContext)
+ roomDetailsEditEntryPoint.createNode(this, buildContext)
}
NavTarget.InviteMembers -> {
@@ -348,7 +349,16 @@ class RoomDetailsFlowNode(
}
is NavTarget.AdminSettings -> {
- rolesAndPermissionsEntryPoint.createNode(this, buildContext)
+ val callback = object : RolesAndPermissionsEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ rolesAndPermissionsEntryPoint.createNode(
+ parentNode = this,
+ buildContext = buildContext,
+ callback = callback,
+ )
}
NavTarget.PinnedMessagesList -> {
val params = MessagesEntryPoint.Params(
@@ -382,7 +392,16 @@ class RoomDetailsFlowNode(
knockRequestsListEntryPoint.createNode(this, buildContext)
}
NavTarget.SecurityAndPrivacy -> {
- securityAndPrivacyEntryPoint.createNode(this, buildContext)
+ val callback = object : SecurityAndPrivacyEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ securityAndPrivacyEntryPoint.createNode(
+ parentNode = this,
+ buildContext = buildContext,
+ callback = callback,
+ )
}
is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 95c4f1e9e2..07b76d41b9 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -18,11 +19,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.knockRequestPermissions
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
-import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState
+import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions
+import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions
+import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
+import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -36,17 +42,13 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.api.room.RoomMembersState
-import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRule
-import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
-import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
+import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
-import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
-import io.element.android.libraries.matrix.ui.room.isDmAsState
-import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
@@ -77,8 +79,6 @@ class RoomDetailsPresenter(
val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val roomInfo by room.roomInfoFlow.collectAsState()
- val isUserAdmin = room.isOwnUserAdmin()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } }
val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } }
@@ -93,15 +93,11 @@ class RoomDetailsPresenter(
observeNotificationSettings()
}
+ val isDm = roomInfo.isDm
val membersState by room.membersStateFlow.collectAsState()
- val canInvite by getCanInvite(membersState)
-
+ val permissions by getPermissions()
val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } }
val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } }
- val isDm by room.isDmAsState()
- val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
- val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
- val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
@@ -109,16 +105,15 @@ class RoomDetailsPresenter(
val roomCallState = roomCallStatePresenter.present()
val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } }
- val topicState = remember(canEditTopic, roomTopic, roomType) {
+ val topicState = remember(permissions.editDetailsPermissions.canEditTopic, roomTopic, roomType) {
val topic = roomTopic
when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
- canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
+ permissions.editDetailsPermissions.canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
else -> RoomTopicState.Hidden
}
}
- val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
val isKnockRequestsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false)
@@ -126,7 +121,10 @@ class RoomDetailsPresenter(
room.knockRequestsFlow.collect { value = it.size }
}
val canShowKnockRequests by remember {
- derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock }
+ derivedStateOf { isKnockRequestsEnabled && permissions.knockRequestsPermissions.hasAny && joinRule == JoinRule.Knock }
+ }
+ val canShowSecurityAndPrivacy by remember {
+ derivedStateOf { !isDm && permissions.securityAndPrivacyPermissions.hasAny(isSpace = false, joinRule = joinRule) }
}
val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow()
@@ -162,13 +160,6 @@ class RoomDetailsPresenter(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
- val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value)
- val canShowSecurityAndPrivacy by remember {
- derivedStateOf {
- roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny
- }
- }
-
val hasMemberVerificationViolations by produceState(false) {
room.roomMemberIdentityStateChange(waitForEncryption = true)
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
@@ -185,15 +176,15 @@ class RoomDetailsPresenter(
roomTopic = topicState,
memberCount = joinedMemberCount,
isEncrypted = isEncrypted,
- canInvite = canInvite,
- canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
+ canInvite = permissions.canInvite,
+ canEdit = roomType == RoomDetailsType.Room && permissions.editDetailsPermissions.hasAny,
roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
- displayRolesAndPermissionsSettings = !isDm && isUserAdmin,
+ displayRolesAndPermissionsSettings = !isDm && permissions.canEditRolesAndPermissions,
isPublic = joinRule == JoinRule.Public,
heroes = roomInfo.heroes.toImmutableList(),
pinnedMessagesCount = pinnedMessagesCount,
@@ -232,14 +223,25 @@ class RoomDetailsPresenter(
}
}
- @Composable
- private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) {
- value = room.canInvite().getOrElse { false }
- }
+ private data class Permissions(
+ val canInvite: Boolean = false,
+ val editDetailsPermissions: RoomDetailsEditPermissions = RoomDetailsEditPermissions.DEFAULT,
+ val knockRequestsPermissions: KnockRequestPermissions = KnockRequestPermissions.DEFAULT,
+ val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT,
+ val canEditRolesAndPermissions: Boolean = false,
+ )
@Composable
- private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) {
- value = room.canSendState(type).getOrElse { false }
+ private fun getPermissions(): State {
+ return room.permissionsAsState(Permissions()) { perms ->
+ Permissions(
+ canInvite = perms.canOwnUserInvite(),
+ editDetailsPermissions = perms.roomDetailsEditPermissions(),
+ knockRequestsPermissions = perms.knockRequestPermissions(),
+ canEditRolesAndPermissions = perms.canEditRolesAndPermissions(),
+ securityAndPrivacyPermissions = perms.securityAndPrivacyPermissions(),
+ )
+ }
}
private fun CoroutineScope.observeNotificationSettings() {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index de0a2cba1b..86481be026 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -59,6 +59,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.modifiers.niceClickable
+import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@@ -404,7 +405,7 @@ private fun RoomHeaderSection(
}.toImmutableList(),
isTombstoned = isTombstoned,
),
- contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_room_avatar) },
+ contentDescription = stringResource(CommonStrings.a11y_room_avatar),
modifier = Modifier
.clickable(
enabled = avatarUrl != null,
@@ -721,6 +722,7 @@ private fun DebugInfoSection(
) {
val context = LocalContext.current
PreferenceCategory(showTopDivider = true) {
+ val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
ListItem(
headlineContent = {
Text("Internal room ID")
@@ -736,8 +738,8 @@ private fun DebugInfoSection(
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())),
onClick = {
context.copyToClipboard(
- roomId.value,
- context.getString(CommonStrings.common_copied_to_clipboard)
+ text = roomId.value,
+ toastMessage = toastMessage,
)
},
)
@@ -767,6 +769,14 @@ internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::clas
internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
ElementPreviewDark { ContentToPreview(state) }
+@PreviewWithLargeHeight
+@Composable
+internal fun RoomDetailsA11yPreview() = ElementPreview {
+ ContentToPreview(
+ state = aRoomDetailsState(displayAdminSettings = true)
+ )
+}
+
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: RoomDetailsState) {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
index f1d3f61f7e..6917057a06 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
@@ -32,10 +32,10 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
-import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
@@ -58,8 +58,7 @@ class RoomMemberListPresenter(
override fun present(): RoomMemberListState {
var searchQuery by rememberSaveable { mutableStateOf("") }
val membersState by room.membersStateFlow.collectAsState()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
- val canInvite by room.canInviteAsState(syncUpdateFlow.value)
+ val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf()) {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
index 007d276dd3..7c928fb27a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
@@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
) {
- val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
+ val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
index 580db7667a..b37738c30f 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
@@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
+import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map
@@ -99,8 +100,10 @@ fun aRoomMemberModerationState(
canKick: Boolean = false,
): RoomMemberModerationState {
return object : RoomMemberModerationState {
- override val canKick: Boolean = canKick
- override val canBan: Boolean = canBan
+ override val permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions(
+ canBan = canBan,
+ canKick = canKick,
+ )
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}
}
diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml
index fe337177df..9b89f2cef0 100644
--- a/features/roomdetails/impl/src/main/res/values-be/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml
@@ -51,7 +51,6 @@
"Замацаваныя паведамленні"
"Профіль"
"Ролі і дазволы"
- "Назва пакоя"
"Бяспека"
"Падзяліцца пакоем"
"Інфармацыя аб пакоі"
@@ -100,7 +99,4 @@
"Ролі"
"Дэталі пакоя"
"Ролі і дазволы"
- "Папрасіце далучыцца"
- "Хто заўгодна"
- "Хто заўгодна"
diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
index 4ea6d24af6..9273f7c38a 100644
--- a/features/roomdetails/impl/src/main/res/values-bg/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
@@ -42,7 +42,6 @@
"Закачени съобщения"
"Профил"
"Роли и разрешения"
- "Име на стаята"
"Защита и поверителност"
"Защита"
"Споделяне на стаята"
@@ -86,14 +85,12 @@
"Шифроване"
"Включване на шифроване от край до край"
"Всеки може да намери и да се присъедини"
- "Всеки"
"Хората могат да се присъединят само ако са поканени"
"Само с покана"
"Достъп до стаята"
"Членове на пространството"
"Пространствата в момента не се поддържат"
"Видима в директорията на обществените стаи"
- "Всеки"
"Кой може да чете историята"
"Само за членове откакто са поканени"
"Само за членове от избирането на тази опция"
diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
index fc19bf400e..78a8a9f703 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -63,7 +63,7 @@
"Profil"
"Žádosti o vstup"
"Role a oprávnění"
- "Název místnosti"
+ "Název"
"Zabezpečení a soukromí"
"Zabezpečení"
"Sdílet místnost"
@@ -132,8 +132,10 @@
"Podrobnosti místnosti"
"Role a oprávnění"
"Přidat adresu"
+ "Připojit se může kdokoli v autorizovaných prostorách, ale všichni ostatní musí o přístup požádat."
"Všichni musí požádat o přístup."
- "Požádat o připojení"
+ "Požádat o vstup"
+ "Kdokoli v %1$s se může připojit, ale všichni ostatní musí o přístup požádat."
"Ano, povolit šifrování"
"Po aktivaci nelze šifrování místnosti deaktivovat. Historie zpráv bude viditelná pouze pro členy místnosti od doby, kdy byli pozváni nebo od té doby, co do místnosti vstoupili.
Nikdo kromě členů místnosti nebude moci číst zprávy. To může bránit správnému fungování robotů a propojení.
@@ -144,7 +146,8 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj
"Povolit koncové šifrování"
"Vstoupit může kdokoli."
"Kdokoliv"
- "Vyberte, kteří členové prostorů se k této místnosti mohou připojit bez pozvánky. %1$s"
+ "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s"
+ "Spravovat prostory"
"Vstoupit mohou pouze pozvaní lidé."
"Pouze pro zvané"
"Přístup"
@@ -157,10 +160,11 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj
"Umožněte nalezení této místnosti prohledáním adresáře veřejných místností na %1$s"
"Umožnit nalezení vyhledáváním ve veřejném adresáři."
"Viditelné ve veřejném adresáři"
- "Kdokoliv"
+ "Kdokoli (historie je veřejná)"
+ "Změny neovlivní starší zprávy, pouze nové. %1$s"
"Kdo může číst historii"
- "Pouze členové od té doby, co byli pozváni"
- "Pouze členové od výběru této možnosti"
+ "Členové od pozvání"
+ "Členové (úplná historie)"
"Adresy místností představují způsoby, jak najít místnosti a získat k nim přístup. Díky tomu můžete svoji místnost snadno sdílet s ostatními.
Můžete se rozhodnout publikovat svou místnost ve veřejném adresáři místnosti vašeho domovského serveru."
"Publikování místnosti"
diff --git a/features/roomdetails/impl/src/main/res/values-cy/translations.xml b/features/roomdetails/impl/src/main/res/values-cy/translations.xml
index 97d39509fa..79de35224e 100644
--- a/features/roomdetails/impl/src/main/res/values-cy/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cy/translations.xml
@@ -63,7 +63,6 @@
"Proffil"
"Ceisiadau i ymuno"
"Rolau a chaniatâd"
- "Enw\'r ystafell"
"Diogelwch a phreifatrwydd"
"Diogelwch"
"Rhannu ystafell"
diff --git a/features/roomdetails/impl/src/main/res/values-da/translations.xml b/features/roomdetails/impl/src/main/res/values-da/translations.xml
index c2ed3caaca..d646b03acb 100644
--- a/features/roomdetails/impl/src/main/res/values-da/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-da/translations.xml
@@ -1,19 +1,21 @@
- "Du skal bruge en rum-adresse for at gøre den synlig i kataloget."
- "Rummets adresse"
+ "Du skal bruge en adresse for at gøre det synligt i det offentlige register."
+ "Redigér adresse"
"Der opstod en fejl under opdatering af notifikationsindstillingen."
"Din hjemmeserver understøtter ikke denne mulighed i krypterede rum, og derfor er det muligt at du ikke får besked i alle rum."
"Afstemninger"
- "Kun admins"
+ "Administrator"
"Spær brugere"
"Fjern beskeder"
- "Invitér personer og acceptér anmodninger om at deltage"
+ "Medlem"
+ "Invitér andre"
+ "Administrer medlemmer"
"Beskeder og indhold"
- "Admins og moderatorer"
- "Fjern personer og afvis anmodninger om at deltage"
+ "Moderator"
+ "Fjern personer"
"Skift rummets avatar"
- "Rediger rum"
+ "Redigér detaljer"
"Skift rummets navn"
"Skift emne for rummet"
"Send beskeder"
@@ -40,7 +42,7 @@
"Krypteret"
"Ikke krypteret"
"Offentligt rum"
- "Rediger rum"
+ "Redigér detaljer"
"Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres."
"Rummet kunne ikke opdateres"
"Beskeder er sikret med låse. Kun du og modtagerne har de unikke nøgler til at låse dem op."
@@ -61,7 +63,6 @@
"Profil"
"Anmodninger om at deltage"
"Roller og tilladelser"
- "Navn på rum"
"Sikkerhed og privatliv"
"Sikkerhed"
"Del rum"
@@ -69,6 +70,12 @@
"Emne"
"Opdaterer rum…"
"Der er ingen spærrede brugere i dette rum."
+
+ - "%1$d Spærret"
+ - "%1$d Spærret"
+
+ "Tjek stavningen eller prøv en ny søgning"
+ "Ingen resultater for \"%1$s\""
- "%1$d person"
- "%1$d personer"
@@ -80,8 +87,13 @@
"Fjern brugerens spærring fra rummet"
"Spærret"
"Medlemmer"
- "Kun admins"
- "Admins og moderatorer"
+
+ - "%1$d Inviteret"
+ - "%1$d Inviteret"
+
+ "Afventer"
+ "Administrator"
+ "Moderator"
"Ejeren"
"Medlemmer af rummet"
"Ophæver spærring af %1$s"
@@ -108,15 +120,15 @@
"Beskeder og indhold"
"Moderatorer"
"Ejere"
+ "Tilladelser"
"Nulstil tilladelser"
"Når du nulstiller tilladelserne, mister du de nuværende indstillinger."
"Nulstil tilladelser?"
"Roller"
"Detaljer om rummet"
"Roller og tilladelser"
- "Tilføj adresse på rum"
- "Alle kan bede om at deltage i lokalet, men en administrator eller moderator skal acceptere anmodningen."
- "Spørg om at deltage"
+ "Tilføj adresse"
+ "Alle skal anmode om adgang."
"Ja, aktivér kryptering"
"Når det først er aktiveret, kan kryptering for et rum ikke deaktiveres igen. Beskedhistorik vil kun være synlig for rummedlemmer, siden de blev inviteret, eller siden de blev medlem af rummet.
Ingen udover medlemmer af rummet vil være i stand til at læse beskeder. Dette kan forhindre bots og broer i at fungere korrekt.
@@ -125,22 +137,23 @@ Vi anbefaler ikke at aktivere kryptering for rum, som alle kan finde og deltage
"Når kryptering først er aktiveret, kan den ikke deaktiveres igen."
"Kryptering"
"Aktivér end-to-end-kryptering"
- "Alle kan finde og deltage"
- "Enhver"
- "Andre kan kun deltage, hvis de bliver inviteret"
- "Kun med invitation"
- "Adgang til rummet"
+ "Alle kan være med."
+ "Kun inviterede personer kan deltage i dette rum."
+ "Kun inviterede"
+ "Adgang"
"Medlemmer af gruppen"
"Grupper understøttes ikke i øjeblikket"
- "Du skal bruge en rum-adresse for at gøre den synlig i kataloget."
+ "Du skal bruge en adresse for at gøre det synligt i det offentlige register."
+ "Adresse"
"Tillad, at dette rum kan findes ved at søge i %1$s fortegnelse over offentlige rum"
- "Synlig i det offentlige register over rum"
- "Enhver"
+ "Gør det muligt at blive fundet ved søgninger i det offentlige register."
+ "Synlig i det offentlige register"
"Hvem kan læse historikken?"
"Kun medlemmer, efter de blev inviteret"
"Kun medlemmer siden valg af denne mulighed"
"Rum-adresser er en måde at finde og få adgang til værelser på. Dette sikrer også, at du nemt kan dele dit rum med andre.
Du kan vælge at offentliggøre dit rum i din hjemmeservers offentlige katalog over rum."
"Udgivelse af rum"
+ "Synlighed"
"Sikkerhed og privatliv"
diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml
index 89ef69c173..0cd1d187a3 100644
--- a/features/roomdetails/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml
@@ -1,17 +1,19 @@
- "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen."
- "Chat-Adresse"
+ "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen."
+ "Chat-Adresse bearbeiten"
"Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."
"Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. In einigen Chats erhältst du möglicherweise keine Benachrichtigungen."
"Umfragen"
- "Nur Admins"
+ "Admin"
"Mitglieder sperren"
"Nachrichten entfernen"
- "Personen einladen und Beitrittsanfragen annehmen"
+ "Mitglied"
+ "Mitglieder hinzufügen"
+ "Mitglieder verwalten"
"Nachrichten senden & löschen"
- "Admins und Moderatoren"
- "Personen entfernen und Beitrittsanfragen ablehnen"
+ "Moderator"
+ "Mitglieder entfernen"
"Avatar ändern"
"Chat bearbeiten"
"Chat-Namen ändern"
@@ -61,7 +63,7 @@
"Profil"
"Beitrittsanfragen"
"Rollen und Berechtigungen"
- "Chat-Name"
+ "Name"
"Sicherheit & Datenschutz"
"Sicherheit"
"Teilen"
@@ -69,6 +71,12 @@
"Thema"
"Chat wird aktualisiert…"
"Es gibt keine gesperrten Nutzer."
+
+ - "%1$d gesperrt"
+ - "%1$d gesperrt"
+
+ "Überprüfe die Schreibweise oder versuch\'s mit einer neuen Suche"
+ "Keine Ergebnisse für „%1$s“"
- "%1$d Person"
- "%1$d Personen"
@@ -80,8 +88,13 @@
"Sperre für diesen Chat aufheben"
"Gesperrt"
"Mitglieder"
- "Nur Admins"
- "Admins und Moderatoren"
+
+ - "%1$d eingeladen"
+ - "%1$d eingeladen"
+
+ "Ausstehend"
+ "Admin"
+ "Moderator"
"Eigentümer"
"Mitglieder"
"%1$s wird entsperrt."
@@ -108,15 +121,18 @@
"Nachrichten senden & löschen"
"Moderatoren"
"Eigentümer"
- "Rollen und Berechtigungen zurücksetzen"
+ "Berechtigungen"
+ "Berechtigungen zurücksetzen"
"Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen."
"Berechtigungen zurücksetzen?"
"Rollen"
"Chat-Details anpassen"
"Rollen und Berechtigungen"
"Chat-Adresse hinzufügen"
- "Jeder kann den Beitritt zum Chat anfragen, aber ein Admin oder Moderator müssen die Anfrage akzeptieren."
- "Beitritt beantragen"
+ "Jedes Mitglied eines autorisierten Space kann beitreten, aber alle anderen müssen einen Beitritt anfragen."
+ "Zugang nur auf Anfrage."
+ "Bitte um Beitritt"
+ "Jeder in %1$s kann beitreten, aber alle anderen müssen den Beitritt anfragen."
"Ja, Verschlüsselung aktivieren"
"Einmal angeschaltet kann die Verschlüsselung für einen Chat nicht mehr deaktiviert werden. Der Nachrichtenverlauf ist für Mitglieder nur sichtbar, seit sie eingeladen wurden oder dem Chat beigetreten sind.
Niemand außer den Chat Mitgliedern kann Nachrichten lesen. Dies kann verhindern, dass Bots und Bridges richtig funktionieren.
@@ -125,24 +141,31 @@ Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden
"Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden."
"Verschlüsselung"
"Ende-zu-Ende-Verschlüsselung aktivieren"
- "Jeder kann diesen Chat finden und ihm beitreten"
+ "Jeder kann beitreten."
"Jeder"
- "Personen können nur beitreten, wenn sie eingeladen werden."
+ "Wähle aus, welche Spaces ihren Mitgliedern ermöglichen sollen, dieser Gruppe ohne Einladung beitreten zu können. %1$s"
+ "Spaces verwalten"
+ "Nur eingeladene Personen können beitreten"
"Nur auf Einladung"
- "Chat Zugang"
+ "Zugang"
+ "Jeder in autorisierten Spaces kann beitreten."
+ "Jeder in %1$s kann beitreten."
"Spacemitglieder"
"Spaces werden zur Zeit nicht unterstützt."
- "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen."
- "Chatroomadresse"
+ "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen."
+ "Adresse"
"Erlaube das Auffinden dieses Chats durch Suche im öffentlichen Verzeichnis von %1$s"
+ "Lass dich über die Suche im öffentlichen Verzeichnis finden."
"Sichtbar im öffentlichen Verzeichnis"
- "Jeder"
+ "Jeder (Nachrichtenverlauf ist öffentlich)"
+ "Änderungen wirken sich nicht auf alte Nachrichten aus, sondern nur auf neue. %1$s"
"Wer hat Zugriff auf den Nachrichtenverlauf"
"Nur Mitglieder, aber erst seit deren Einladung"
- "Nur Mitglieder seit Auswahl dieser Option"
+ "Mitglieder (voller Nachrichtenverlauf)"
"Chat-Adressen machen es möglich, Chats zu finden und ihnen beizutreten. Dies erleichtert es, Chats mit anderen zu teilen.
Auf Wunsch kannst du deinen Chat im öffentlichen Verzeichnis deines Homeservers veröffentlichen."
"Veröffentlichung von Chats"
- "Chatroomsichtbarkeit."
+ "Adressen ermöglichen es, Gruppen und Spaces zu finden und zu betreten. Dadurch wird auch sichergestellt, dass diese problemlos mit anderen geteilt werden können."
+ "Sichtbarkeit"
"Sicherheit & Datenschutz"
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 a2deb1c706..eeba5d5bf7 100644
--- a/features/roomdetails/impl/src/main/res/values-el/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml
@@ -55,7 +55,6 @@
"Προφίλ"
"Αιτήματα συμμετοχής"
"Ρόλοι και δικαιώματα"
- "Όνομα αίθουσας"
"Ασφάλεια & απόρρητο"
"Ασφάλεια"
"Κοινή χρήση αίθουσας"
@@ -126,7 +125,6 @@
"Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο."
"Επιστρέψτε την εύρεση αυτής της αίθουσας με αναζήτηση στον κατάλογο %1$s δημοσίων αιθουσών"
"Ορατή στον κατάλογο δημόσιων αιθουσών"
- "Οποιοσδήποτε"
"Ποιος μπορεί να διαβάσει το ιστορικό"
"Μόνο μέλη από τη στιγμή που προσκλήθηκαν"
"Μόνο για μέλη μετά από αυτήν την επιλογή"
diff --git a/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml b/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml
new file mode 100644
index 0000000000..9a4b56e287
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Anyone in authorized spaces can join, but everyone else must request access."
+ "Anyone in authorized spaces can join."
+
diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml
index c179a4d16e..bd3bfabc79 100644
--- a/features/roomdetails/impl/src/main/res/values-es/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml
@@ -55,7 +55,6 @@
"Perfil"
"Solicitudes de unión"
"Roles y permisos"
- "Nombre de la sala"
"Seguridad y privacidad"
"Seguridad"
"Compartir sala"
@@ -107,7 +106,6 @@
"Roles y permisos"
"Agregar dirección de sala"
"Cualquiera puede solicitar unirse a la sala, pero un administrador o moderador tendrá que aceptar la solicitud."
- "Solicitud para unirse"
"Sí, activar cifrado"
"Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala.
Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente.
@@ -117,7 +115,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr
"Cifrado"
"Activar el cifrado de extremo a extremo"
"Cualquiera puede encontrarla y unirse"
- "Cualquiera"
"Las personas solo pueden unirse si están invitadas"
"Solo por invitación"
"Acceso a la sala"
@@ -126,7 +123,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr
"Necesitarás una dirección de sala para que sea visible en el directorio."
"Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s"
"Visible en el directorio de salas públicas"
- "Cualquiera"
"Quién puede leer el historial"
"Solo participantes desde que fueron invitados"
"Solo participantes desde que se selecciona esta opción"
diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml
index 7a315de168..65e39cf045 100644
--- a/features/roomdetails/impl/src/main/res/values-et/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml
@@ -63,7 +63,7 @@
"Profiil"
"Liitumispalved"
"Rollid ja õigused"
- "Jututoa nimi"
+ "Nimi"
"Turvalisus ja privaatsus"
"Turvalisus"
"Jaga jututuba"
@@ -71,6 +71,10 @@
"Teema"
"Uuendame jututuba…"
"Suhtluskeeluga kasutajaid pole"
+
+ - "%1$d suhtluskeeluga kasutaja"
+ - "%1$d suhtluskeeluga kasutajat"
+
"Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti"
"Otsingul „%1$s“ pole tulemusi"
@@ -84,6 +88,10 @@
"Eemalda suhtluskeeld jututoas"
"Suhtluskeeluga kasutajad"
"Liikmed"
+
+ - "%1$d saatis kutse"
+ - "%1$d saatis kutse"
+
"Ootel"
"Peakasutajad"
"Moderaatorid"
@@ -121,8 +129,10 @@
"Jututoa üksikasjad"
"Rollid ja õigused"
"Lisa aadress"
+ "Liituda saavad kõik volitatud kogukondade liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks."
"Kõik võivad paluda jututoaga liitumist."
- "Küsi võimalust liitumiseks"
+ "Palu võimalust liituda"
+ "Liituda saavad kõik „%1$s“ kogukonna liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks."
"Jah, lülita krüptimine sisse"
"Kui jututoa krüptimine on kord sisse lülitatud, siis seda välja lülitada ei saa. Sõnumite ajalugu on nähtav vaid jututoa liikmetele alates kutse saamise või liitumise hetkest.
Keegi teine peale jututoa liikmete ei saa sõnumeid lugeda. See võib takistada suhtlusrobotite ja/või võrgusildade toimimist.
@@ -132,10 +142,14 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
"Krüptimine"
"Võta läbiv krüptimine kasutusele"
"Kõik võivad jututoaga liituda"
- "Kõik"
+ "Avalik"
+ "Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"
+ "Halda kogukondi"
"Liituda saab vaid kutse olemasolul"
"Vaid kutsega"
"Ligipääs"
+ "Liituda saavad kõik volitatud kogukondade liikmed."
+ "Liituda võivad kõik „%1$s“ liikmed."
"Kogukonna liikmed"
"Kogukondade tugi veel puudub"
"Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."
@@ -143,13 +157,15 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
"Võimalda leida seda jututuba avalikust kataloogist otsides „%1$s“"
"Luba leitavus avaliku kataloogi otsingust."
"Nähtav avalikus kataloogis"
- "Kõik"
+ "Kõik (ajalugu on avalik)"
+ "Muudatused ei mõjuta varasemaid sõnumeid, ainult uusi. %1$s"
"Kes võivad lugeda jututoa ajalugu"
"Liikmed peale kutse saamist"
- "Liikmed peale selle valiku sisselülitamist"
+ "Liikmed (terviklik ajalugu)"
"Jututoa aadressid annavad võimaluse neid leida ning saada neile ligi. Samuti võimaldab see jututuba teistele huvilistele jagada.
Lisaks võid sa jututoa avaldada oma koduserveri avalikus jututubade kataloogis."
"Jututoa avaldamine"
+ "Aadressid on mugav viis jututubade ja kogukondade leidmiseks ning tagab mugava võimaluse nende jagamiseks."
"Nähtavus"
"Turvalisus ja privaatsus"
diff --git a/features/roomdetails/impl/src/main/res/values-eu/translations.xml b/features/roomdetails/impl/src/main/res/values-eu/translations.xml
index 5d0f61fb40..8f90f44a8a 100644
--- a/features/roomdetails/impl/src/main/res/values-eu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-eu/translations.xml
@@ -53,7 +53,6 @@
"Profila"
"Sartzeko eskaerak"
"Rolak eta baimenak"
- "Gelaren izena"
"Segurtasuna eta pribatutasuna"
"Segurtasuna"
"Partekatu gela"
@@ -104,7 +103,6 @@
"Bai, gaitu zifratzea"
"Zifratzea"
"Edonork aurkitu eta bat egin dezake"
- "Edonork"
"Gonbidatutako pertsonak bakarrik sartu ahal izango dira"
"Gonbidapen bidez"
"Gelarako sarbidea"
@@ -112,7 +110,6 @@
"Gaur-gaurkoz ez da guneekin bateragarria"
"Gelaren helbidea"
"Gela publikoen direktorioan ikusgai"
- "Edonork"
"Nork irakur dezake historia"
"Kideek bakarrik, gonbidatu zituztenetik"
"Kideek bakarrik, aukera hau hautatu zenetik"
diff --git a/features/roomdetails/impl/src/main/res/values-fa/translations.xml b/features/roomdetails/impl/src/main/res/values-fa/translations.xml
index c04650417c..7ae439b983 100644
--- a/features/roomdetails/impl/src/main/res/values-fa/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fa/translations.xml
@@ -61,7 +61,6 @@
"نمایه"
"درخواستهای پیوستن"
"نقشها و اجازهها"
- "نام اتاق"
"امنیت و محرمانگی"
"امنیت"
"همرسانی اتاق"
diff --git a/features/roomdetails/impl/src/main/res/values-fi/translations.xml b/features/roomdetails/impl/src/main/res/values-fi/translations.xml
index b696660b62..e6a7105027 100644
--- a/features/roomdetails/impl/src/main/res/values-fi/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fi/translations.xml
@@ -63,7 +63,6 @@
"Profiili"
"Liittymispyynnöt"
"Roolit ja oikeudet"
- "Huoneen nimi"
"Turvallisuus ja yksityisyys"
"Turvallisuus"
"Jaa huone"
@@ -143,7 +142,7 @@ Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa vo
"Kuka tahansa"
"Kuka voi lukea viestihistoriaa"
"Jäsenet vasta kutsusta lähtien"
- "Jäsenet tämän vaihtoehdon valinnan jälkeen"
+ "Jäsenet (koko historia)"
"Huoneosoitteet ovat tapoja löytää ja käyttää huoneita. Näin voit myös helposti jakaa huoneesi muiden kanssa.
Voit halutessasi julkaista huoneesi kotipalvelimesi julkisessa huonehakemistossa."
"Huoneen julkaiseminen"
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index 79af58f0e3..3445cf4802 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -63,7 +63,7 @@
"Profil"
"Demandes en attente"
"Rôles & autorisations"
- "Nom du salon"
+ "Nom"
"Sécurité & confidentialité"
"Sécurité"
"Partager le salon"
@@ -129,8 +129,10 @@
"Détails du salon"
"Rôles & autorisations"
"Ajouter une adresse"
+ "Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander l’accès."
"Tout le monde doit demander un accès."
"Demander à rejoindre"
+ "Tout membre de %1$s peut rejoindre l’espace, mais les autres doivent demander un accès."
"Oui, activer le chiffrement"
"Une fois activé, le chiffrement d’un salon ne peut pas être désactivé. L’historique des messages ne sera visible que pour les membres depuis qu’ils ont été invités ou depuis qu’ils ont rejoint le salon.
Personne d’autre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement.
@@ -141,9 +143,13 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le
"Activer le chiffrement de bout en bout"
"Tout le monde peut rejoindre."
"Tout le monde"
+ "Choisissez les espaces dont les membres peuvent rejoindre ce salon sans invitation. %1$s"
+ "Gérer les espaces"
"Seules les personnes invitées peuvent rejoindre."
"Sur invitation uniquement"
"Accès"
+ "Toute personne se trouvant dans un espace autorisé peut joindre le salon."
+ "Toute personne de l’espace %1$s peut joindre le salon."
"Membres de l’espace"
"Les Espaces ne sont pas encore supportés"
"Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public."
@@ -151,10 +157,11 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le
"Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics"
"Permet d’être trouvé en recherchant dans l’annuaire public."
"Visible dans l’annuaire public"
- "Tout le monde"
+ "Tout le monde (l’historique est public)"
+ "Les changements n’affecteront pas les anciens messages, seulement les nouveaux. %1$s"
"Qui peux lire l’historique"
- "Les membres uniquement depuis qu’ils ont été invités"
- "Les membres uniquement depuis la sélection de cette option"
+ "Seulement les membres, depuis leur invitation"
+ "Membres (historique complet)"
"Les adresses de salon sont un moyen de trouver et d’accéder aux salons. Cela vous permet également de partager facilement votre salon avec d’autres personnes.
Vous pouvez choisir de publier votre salon dans l’annuaire des salons publics de votre serveur d’accueil."
"Publication du salon"
diff --git a/features/roomdetails/impl/src/main/res/values-hr/translations.xml b/features/roomdetails/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..037b43be8a
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,172 @@
+
+
+ "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju."
+ "Uredi adresu"
+ "Došlo je do pogreške prilikom ažuriranja postavke obavijesti."
+ "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama."
+ "Ankete"
+ "Administrator"
+ "Zabrana pristupa osobama"
+ "Uklanjanje poruka"
+ "Član"
+ "Pozivanje osoba"
+ "Upravljanje članovima"
+ "Poruke i sadržaj"
+ "Moderator"
+ "Uklanjanje osoba"
+ "Promjena avatara"
+ "Uredi pojedinosti"
+ "Promjena imena"
+ "Promjena teme"
+ "Slanje poruka"
+ "Uredi administratore"
+ "Nećete moći poništiti ovu radnju. Postavit ćete da korisnik ima isti položaj kao i vi."
+ "Dodati administratora?"
+ "Nećete moći poništiti ovu radnju. Prenosite vlasništvo na odabrane korisnike. Nakon što odete, to će biti trajno."
+ "Želite li prenijeti vlasništvo?"
+ "Degradiraj"
+ "Nećete moći poništiti ovu promjenu jer sami sebe degradirate. Ako ste posljednji privilegirani korisnik u sobi, nećete moći ponovno dobiti privilegije."
+ "Želite li se degradirati?"
+ "%1$s (na čekanju)"
+ "(na čekanju)"
+ "Administratori automatski imaju moderatorske ovlasti"
+ "Vlasnici automatski imaju administratorske ovlasti."
+ "Uredi moderatore"
+ "Odaberi vlasnike"
+ "Administratori"
+ "Moderatori"
+ "Članovi"
+ "Niste spremili sve promjene."
+ "Želite li spremiti promjene?"
+ "Dodaj temu"
+ "Šifrirano"
+ "Nije šifrirano"
+ "Javna soba"
+ "Uredi pojedinosti"
+ "Došlo je do nepoznate pogreške i podatci se nisu mogli promijeniti."
+ "Nije moguće ažurirati sobu"
+ "Poruke su zaključane. Samo vi i primatelji imate jedinstvene ključeve za njihovo otključavanje."
+ "Omogućeno je šifriranje poruka"
+ "Došlo je do pogreške prilikom učitavanja postavki obavijesti."
+ "Isključivanje zvuka u ovoj sobi nije uspjelo, pokušajte ponovno."
+ "Uključivanje zvuka u ovoj sobi nije uspjelo, pokušajte ponovno."
+ "Ne zatvarajte aplikaciju dok se ne završi."
+ "Priprema pozivnica…"
+ "Pozovi osobe"
+ "Napusti razgovor"
+ "Napusti sobu"
+ "Mediji i datoteke"
+ "Prilagođeno"
+ "Zadano"
+ "Obavijesti"
+ "Prikvačene poruke"
+ "Profil"
+ "Zahtjevi za pridruživanje"
+ "Uloge i dopuštenja"
+ "Naziv"
+ "Sigurnost i privatnost"
+ "Sigurnost"
+ "Podijeli sobu"
+ "Informacije o sobi"
+ "Tema"
+ "Ažuriranje pojedinosti…"
+ "Nema zabranjenih korisnika."
+
+ - "%1$d zabranjen"
+ - "%1$d zabranjena"
+ - "%1$d zabranjenih"
+
+ "Provjerite pravopis ili pokušajte s novim pretraživanjem"
+ "Nema rezultata za “%1$s”"
+
+ - "%1$d osoba"
+ - "%1$d osobe"
+ - "%1$d ljudi"
+
+ "Zabrani korisnika"
+ "Samo ukloni člana"
+ "Poništi zabranu"
+ "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani."
+ "Poništi zabranu pristupa korisniku"
+ "Zabranjeni"
+ "Članovi"
+
+ - "%1$d pozvan"
+ - "%1$d pozvana"
+ - "%1$d pozvanih"
+
+ "Na čekanju"
+ "Administrator"
+ "Moderator"
+ "Vlasnik"
+ "Članovi sobe"
+ "Uklanja se zabrana korisniku %1$s"
+ "Dopusti prilagođenu postavku"
+ "Uključivanjem ovoga poništit ćete zadanu postavku"
+ "Obavijesti me u ovom razgovoru za"
+ "Možete to promijeniti u %1$s ."
+ "globalne postavke"
+ "Zadana postavka"
+ "Ukloni prilagođenu postavku"
+ "Došlo je do pogreške prilikom učitavanja postavki obavijesti."
+ "Vraćanje zadanog načina rada nije uspjelo, pokušajte ponovno."
+ "Postavljanje načina rada nije uspjelo, pokušajte ponovno."
+ "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; nećete dobiti obavijest u ovoj sobi."
+ "Sve poruke"
+ "Samo spominjanja i ključne riječi"
+ "U ovoj sobi obavijesti me za"
+ "Administratori"
+ "Administratori i vlasnici"
+ "Promijeni moju ulogu"
+ "Degradiraj u člana"
+ "Degradiraj u moderatora"
+ "Moderiranje članova"
+ "Poruke i sadržaj"
+ "Moderatori"
+ "Vlasnici"
+ "Dopuštenja"
+ "Poništi dopuštenja"
+ "Nakon što poništite dopuštenja, izgubit ćete trenutačne postavke."
+ "Želite li poništiti dopuštenja?"
+ "Uloge"
+ "Pojedinosti o sobi"
+ "Uloge i dopuštenja"
+ "Dodaj adresu"
+ "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti, ali svi ostali moraju zatražiti pristup."
+ "Svi moraju zatražiti pristup."
+ "Svatko u %1$s može se pridružiti, ali svi ostali moraju zatražiti pristup."
+ "Da, omogući šifriranje"
+ "Nakon što se šifriranje za sobu omogući, više se neće moći onemogućiti. Povijest poruka bit će vidljiva samo članovima sobe otkad su pozvani ili otkad su joj se pridružili.
+Nitko osim članova sobe neće moći čitati poruke. Zbog toga botovi i mostovi možda neće ispravno funkcionirati.
+Ne preporučujemo omogućavanje šifriranja za sobe koje svatko može pronaći i pridružiti im se."
+ "Želite li omogućiti šifriranje?"
+ "Nakon što se šifriranje omogući, više se neće moći onemogućiti."
+ "Šifriranje"
+ "Omogući sveobuhvatno šifriranje"
+ "Svatko se može pridružiti."
+ "Odaberite iz kojih se prostora članovi mogu pridružiti ovoj sobi bez pozivnice. %1$s"
+ "Upravljaj prostorima"
+ "Samo pozvane osobe mogu se pridružiti."
+ "Samo s pozivnicom"
+ "Pristup"
+ "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti."
+ "Svatko u %1$s može se pridružiti."
+ "Članovi prostora"
+ "Prostori trenutačno nisu podržani"
+ "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju."
+ "Adresa"
+ "Omogući pronalazak ove sobe pretraživanjem %1$s javnog direktorija soba"
+ "Omogući pronalazak pretraživanjem javnog direktorija."
+ "Vidljivo u javnom direktoriju"
+ "Svatko (povijest je javna)"
+ "Promjene neće utjecati na prethodne poruke, samo na nove. %1$s"
+ "Tko zna čitati povijest"
+ "Samo za članove nakon što su pozvani"
+ "Članovi (cjelokupna povijest)"
+ "Adrese soba služe za pronalaženje i pristup sobama. Time se također osigurava jednostavno dijeljenje sobe s drugim korisnicima.
+Možete odabrati objavljivanje svoje sobe u javnom direktoriju soba na matičnom poslužitelju."
+ "Objavljivanje sobe"
+ "Adrese služe za pronalaženje soba i prostora te pristup njima. Tako ih ujedno možete jednostavno dijeliti s drugima."
+ "Vidljivost"
+ "Sigurnost i privatnost"
+
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index bbcf93fcf7..a549ef437c 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -63,7 +63,6 @@
"Profil"
"Csatlakozási kérelem"
"Szerepkörök és jogosultságok"
- "Szoba neve"
"Biztonság és adatvédelem"
"Biztonság"
"Szoba megosztása"
@@ -71,6 +70,8 @@
"Téma"
"Szoba frissítése…"
"Ebben a szobában nincsenek kitiltott felhasználók."
+ "Ellenőrizze a helyesírást, vagy próbáljon meg egy új keresést"
+ "Nincs találat a következőre: „%1$s\""
- "%1$d személy"
- "%1$d személy"
@@ -130,7 +131,7 @@ Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket b
"Titkosítás"
"Végpontok közötti titkosítás engedélyezése"
"Bárki csatlakozhat."
- "Bárki"
+ "Nyilvános"
"Csak a meghívott emberek léphetnek be."
"Csak meghívásos"
"Hozzáférés"
diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml
index 097cd371f6..27a1a5745e 100644
--- a/features/roomdetails/impl/src/main/res/values-in/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml
@@ -55,7 +55,6 @@
"Profil"
"Permintaan untuk bergabung"
"Peran dan perizinan"
- "Nama ruangan"
"Keamanan & privasi"
"Keamanan"
"Bagikan ruangan"
@@ -106,7 +105,6 @@
"Peran dan perizinan"
"Tambahkan alamat ruangan"
"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut."
- "Minta untuk bergabung"
"Ya, aktifkan enkripsi"
"Setelah diaktifkan, encryption untuk sebuah ruangan tidak dapat dinonaktifkan, Riwayat pesan hanya akan terlihat oleh anggota ruangan sejak mereka diundang atau sejak mereka bergabung dengan ruangan tersebut.
Tidak ada orang lain selain anggota ruangan yang dapat membaca pesan. Hal ini dapat mencegah bot dan jembatan bekerja dengan benar.
@@ -116,7 +114,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite
"Enkripsi"
"Aktifkan enkripsi ujung ke ujung"
"Siapa pun dapat menemukan dan bergabung"
- "Siapa pun"
"Orang hanya dapat bergabung jika mereka diundang"
"Hanya undangan"
"Akses ruangan"
@@ -125,7 +122,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite
"Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori."
"Izinkan ruangan ini ditemukan dengan mencari direktori ruangan %1$s publik"
"Terlihat di direktori ruangan publik"
- "Siapa pun"
"Siapa yang bisa membaca riwayat"
"Hanya anggota sejak mereka diundang"
"Hanya anggota sejak memilih opsi ini"
diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml
index 3d83dba3cb..6d73fc83c9 100644
--- a/features/roomdetails/impl/src/main/res/values-it/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml
@@ -63,7 +63,6 @@
"Profilo"
"Richieste di accesso"
"Ruoli e autorizzazioni"
- "Nome stanza"
"Sicurezza e privacy"
"Sicurezza"
"Condividi stanza"
diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml
index d9c56aa89c..3ffc962603 100644
--- a/features/roomdetails/impl/src/main/res/values-ka/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml
@@ -46,7 +46,6 @@
"შეტყობინებები"
"პროფილი"
"როლები და ნებართვები"
- "ოთახის სახელი"
"უსაფრთხოება"
"ოთახის გაზიარება"
"ოთახის ინფორმაცია"
diff --git a/features/roomdetails/impl/src/main/res/values-ko/translations.xml b/features/roomdetails/impl/src/main/res/values-ko/translations.xml
index 22f3fe99e6..7c53921655 100644
--- a/features/roomdetails/impl/src/main/res/values-ko/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ko/translations.xml
@@ -59,7 +59,6 @@
"프로필"
"참여 요청"
"역할 및 권한"
- "방 이름"
"보안 및 개인정보 보호"
"보안"
"방 공유하기"
@@ -113,7 +112,6 @@
"역할 및 권한"
"방 주소 추가"
"누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다."
- "참가 요청"
"예, 암호화 활성화"
"일단 활성화되면, 방의 암호화는 비활성화할 수 없습니다. 메시지 기록은 방에 초대된 후 또는 방에 참여한 이후부터 방 구성원만 볼 수 있습니다.
방 구성원 외에는 아무도 메시지를 읽을 수 없습니다. 이로 인해 봇과 브리지가 제대로 작동하지 않을 수 있습니다.
@@ -123,7 +121,6 @@
"암호화"
"종단간 암호화 활성화"
"누구나 찾을 수 있고 참여할 수 있습니다."
- "누구나"
"초대받은 사용자만 가입할 수 있습니다."
"초대 전용"
"방 액세스"
@@ -132,7 +129,6 @@
"디렉토리에 표시하려면 방 주소가 필요합니다."
"%1$s 공개 방 디렉토리에서 이 방을 검색할 수 있도록 허용합니다"
"공개 룸 디렉토리에 표시됨"
- "누구나"
"누가 기록을 읽을 수 있는가"
"초대받은 회원만 이용 가능합니다"
"이 옵션을 선택한 회원만 이용 가능합니다."
diff --git a/features/roomdetails/impl/src/main/res/values-lt/translations.xml b/features/roomdetails/impl/src/main/res/values-lt/translations.xml
index 727875d696..fc0e84f6cc 100644
--- a/features/roomdetails/impl/src/main/res/values-lt/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-lt/translations.xml
@@ -10,7 +10,6 @@
"Pakviesti žmonių"
"Palikti pokalbį"
"Palikti kambarį"
- "Kambario pavadinimas"
"Saugumas"
"Bendrinti kambarį"
"Tema"
diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
index 7d23507a8b..d28c16f0ca 100644
--- a/features/roomdetails/impl/src/main/res/values-nb/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
@@ -1,19 +1,21 @@
- "Du trenger en adresse til rommet for å gjøre det synlig i katalogen."
- "Romadresse"
+ "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
+ "Rediger adresse"
"Det oppstod en feil under oppdatering av varslingsinnstillingen."
"Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og det kan hende at du ikke blir varslet i enkelte rom."
"Avstemninger"
- "Kun for administratorer"
+ "Admin"
"Forby folk"
"Fjern meldinger"
- "Inviter folk og godta forespørsler om å bli med"
+ "Medlem"
+ "Inviter folk"
+ "Administrer medlemmer"
"Meldinger og innhold"
- "Administratorer og moderatorer"
- "Fjern folk og avslå forespørsler om å bli med"
+ "Moderator"
+ "Fjern folk"
"Endre romavatar"
- "Rediger rom"
+ "Rediger detaljer"
"Endre romnavn"
"Endre temaet til rommet"
"Send meldinger"
@@ -40,7 +42,7 @@
"Kryptert"
"Ikke kryptert"
"Offentlig rom"
- "Rediger rom"
+ "Rediger detaljer"
"Det oppstod en ukjent feil, og informasjonen kunne ikke endres."
"Kan ikke oppdatere rommet"
"Meldingene er krypterte. Det er bare du og mottakerne som har de unike nøklene til å låse dem opp."
@@ -61,14 +63,13 @@
"Profil"
"Forespørsler om å bli med"
"Roller og tillatelser"
- "Romnavn"
"Sikkerhet og personvern"
"Sikkerhet"
"Del rom"
"Informasjon om rommet"
"Emne"
"Oppdaterer rommet …"
- "Det er ingen utestengte brukere i dette rommet."
+ "Det er ingen utestengte brukere."
- "%1$d person"
- "%1$d personer"
@@ -80,8 +81,8 @@
"Fjern utestengelsen fra rommet"
"Utestengt"
"Medlemmer"
- "Kun for administratorer"
- "Administratorer og moderatorer"
+ "Admin"
+ "Moderator"
"Eier"
"Medlemmer av rommet"
"Oppheve utestengelsen av %1$s"
@@ -114,9 +115,8 @@
"Roller"
"Romdetaljer"
"Roller og tillatelser"
- "Legg til romadresse"
- "Alle kan be om å bli med i rommet, men en administrator eller moderator må godta forespørselen."
- "Be om å bli med"
+ "Legg til adresse"
+ "Alle må be om tilgang."
"Ja, aktiver kryptering"
"Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet.
Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal.
@@ -125,22 +125,23 @@ Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og
"Når kryptering er aktivert, kan det ikke deaktiveres."
"Kryptering"
"Aktiver ende-til-ende-kryptering"
- "Alle kan finne og bli med"
- "Alle"
- "Folk kan bare bli med hvis de er invitert"
+ "Alle kan bli med."
+ "Bare inviterte personer kan bli med."
"Kun for inviterte"
- "Tilgang til rom"
+ "Tilgang"
"Medlemmer av område"
"Områder støttes ikke for øyeblikket"
- "Du trenger en adresse til rommet for å gjøre det synlig i katalogen."
+ "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
+ "Adresse"
"Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog"
- "Synlig i offentlig romkatalog"
- "Alle"
+ "Synlig i offentlig katalog"
+ "Alle (historikken er offentlig)"
"Hvem kan lese historikk"
- "Medlemmer bare siden de ble invitert"
- "Kun medlemmer siden du valgte dette alternativet"
+ "Medlemmer siden de ble invitert"
+ "Medlemmer (full historikk)"
"Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre.
Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog."
"Publisering av rom"
+ "Synlighet"
"Sikkerhet og personvern"
diff --git a/features/roomdetails/impl/src/main/res/values-nl/translations.xml b/features/roomdetails/impl/src/main/res/values-nl/translations.xml
index 73e7b7f8c6..f6655f690d 100644
--- a/features/roomdetails/impl/src/main/res/values-nl/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-nl/translations.xml
@@ -51,7 +51,6 @@
"Vastgezette berichten"
"Profiel"
"Rollen en rechten"
- "Naam van de kamer"
"Beveiliging"
"Kamer delen"
"Kamer info"
@@ -99,7 +98,4 @@
"Rollen"
"Kamergegevens"
"Rollen en rechten"
- "Vraag om toe te treden"
- "Iedereen"
- "Iedereen"
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 a24774c736..551e4cf5c8 100644
--- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml
@@ -61,7 +61,6 @@
"Profil"
"Prośby o dołączenie"
"Role i uprawnienia"
- "Nazwa pokoju"
"Bezpieczeństwo i prywatność"
"Bezpieczeństwo"
"Udostępnij pokój"
@@ -127,7 +126,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do
"Szyfrowanie"
"Włącz szyfrowanie end-to-end"
"Każdy może znaleźć i dołączyć"
- "Wszyscy"
+ "Każdy"
"Tylko osoby z zaproszeniem mogą dołączyć"
"Tylko zaproszenie"
"Dostęp do pokoju"
@@ -137,7 +136,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do
"Adres pokoju"
"Zezwól na znalezienie tego pokoju wyszukując %1$s w katalogu pokoi publicznych"
"Widoczny w katalogu pokoi publicznych"
- "Wszyscy"
+ "Ktokolwiek"
"Kto może czytać historię"
"Od momentu kiedy członkowie zostali zaproszeni"
"Członkowie od momentu włączenia tej opcji"
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 da19da98ed..b24f4e5716 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
@@ -63,7 +63,7 @@
"Perfil"
"Pedidos de entrada"
"Cargos e permissões"
- "Nome da sala"
+ "Nome"
"Segurança e privacidade"
"Segurança"
"Compartilhar sala"
@@ -129,8 +129,9 @@
"Detalhes da sala"
"Cargos e permissões"
"Adicionar endereço"
+ "Qualquer um nos espaços autorizados podem entrar, mas todos os outros devem pedir acesso."
"Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido."
- "Pedir para entrar"
+ "Qualquer um em %1$s pode entrar, mas todos os outros devem pedir acesso."
"Sim, ativar a criptografia"
"Uma vez ativada, a criptografia de uma sala não pode ser desativada. O histórico de mensagens só será visível para os membros da sala desde que foram convidados ou desde que entraram na sala.
Ninguém além dos membros da sala poderá ler as mensagens. Isso pode impedir que os bots e as pontes funcionem corretamente.
@@ -140,10 +141,13 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa
"Criptografia"
"Ativar a criptografia de ponta a ponta"
"Qualquer um pode entrar"
- "Qualquer pessoa"
+ "Escolha os espaços dos quais os membros podem entrar nesta sala sem um convite. %1$s"
+ "Gerenciar espaços"
"Apenas pessoas convidadas podem entrar."
"Privado"
"Acesso"
+ "Qualquer um em espaços autorizados podem entrar."
+ "Qualquer pessoa em %1$s pode participar."
"Membros do espaço"
"No momento, não há suporte aos espaços"
"Você precisará de um endereço para torná-la visível no diretório."
@@ -151,10 +155,11 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa
"Permitir que esta sala seja encontrada pesquisando diretório de salas públicas de %1$s"
"Permite que seja encontrada ao buscar no diretório público."
"Visível no diretório público"
- "Qualquer pessoa"
+ "Qualquer um (histórico público)"
+ "As alterações não afetarão mensagens anteriores, somente as novas. %1$s"
"Quem pode ler o histórico"
- "Somente membros, desde que foram convidados"
- "Somente para membros após selecionar esta opção"
+ "Membros desde o convite"
+ "Membros (histórico completo)"
"Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhar facilmente sua sala com outras pessoas.
Você pode optar por publicar sua sala no diretório público de salas do seu servidor-casa."
"Publicação da sala"
diff --git a/features/roomdetails/impl/src/main/res/values-pt/translations.xml b/features/roomdetails/impl/src/main/res/values-pt/translations.xml
index b682c0597d..6e75c110ee 100644
--- a/features/roomdetails/impl/src/main/res/values-pt/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-pt/translations.xml
@@ -61,7 +61,6 @@
"Perfil"
"Pedidos de entrada"
"Cargos e permissões"
- "Nome da sala"
"Segurança e privacidade"
"Segurança"
"Partilhar sala"
@@ -116,7 +115,7 @@
"Cargos e permissões"
"Adicionar endereço de sala"
"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador tem que aceitar o pedido."
- "Pedir para participar"
+ "Pedir para entrar"
"Sim, ativar cifragem"
"Uma vez ativada, a cifragem não pode ser desativada. O histórico de mensagens só será visível a membros a partir do momento em que foram convidados ou que entraram na sala.
Ninguém além dos membros poderão ler quaisquer mensagens. Isto pode impedir que robôs (\"bots\") e pontes (\"bridges\") funcionem devidamente.
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index f0c43e53a5..3ac69c59a5 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -1,19 +1,21 @@
- "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director."
- "Adresa camerei"
+ "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public."
+ "Editați adresa"
"A apărut o eroare în timpul actualizării setărilor pentru notificari."
"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."
"Sondaje"
- "Doar administratori"
+ "Administrator"
"Interziceți persoane"
"Ștergeți mesajele"
- "Invitați persoane și acceptați cereri de alaturare"
+ "Membru"
+ "Invitați persoane"
+ "Gestionați membrii"
"Mesaje și conținut"
- "Administratori și moderatori"
- "Îndepărtați persoane și refuzați cereri de alăturare"
+ "Moderator"
+ "Îndepărtați persoane"
"Schimbați avatarul camerei"
- "Editați camera"
+ "Editați detaliile"
"Schimbă numele camerei"
"Schimbați subiectul camerei"
"Trimiteți mesaje"
@@ -40,7 +42,7 @@
"Criptat"
"Necriptat"
"Cameră publică"
- "Editați camera"
+ "Editați detaliile"
"A apărut o eroare la actualizarea detaliilor camerei"
"Nu s-a putut actualiza camera"
"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."
@@ -61,16 +63,24 @@
"Profil"
"Cereri de alăturare"
"Roluri și permisiuni"
- "Numele camerei"
+ "Nume"
"Securitate & confidențialitate"
"Securitate"
"Partajați camera"
"Informatii camera"
"Subiect"
"Se actualizează camera…"
- "Nu există utilizatori interziși în această cameră."
+ "Nu există utilizatori interziși."
+
+ - "%1$d Interzis"
+ - "%1$d Interziși"
+ - "%1$d Interziși"
+
+ "Verificați ortografia sau încercați o căutare nouă"
+ "Niciun rezultat pentru “%1$s”"
- - "o persoană"
+ - "%1$d persoană"
+ - "%1$d persoane"
- "%1$d persoane"
"Îndepărtați și interziceți membrul"
@@ -80,8 +90,14 @@
"Revocati excluderea din camera"
"Excluși"
"Membri"
- "Doar administratori"
- "Administratori și moderatori"
+
+ - "%1$d Invitat"
+ - "%1$d Invitați"
+ - "%1$d Invitați"
+
+ "În așteptare"
+ "Administrator"
+ "Moderator"
"Proprietar"
"Membrii camerei"
"Se anulează interzicerea lui %1$s"
@@ -108,15 +124,17 @@
"Mesaje și conținut"
"Moderatori"
"Proprietari"
+ "Permisiuni"
"Resetați permisiunile"
"După ce resetați permisiunile, veți pierde setările curente."
"Resetați permisiunile?"
"Roluri"
"Detaliile camerei"
"Roluri și permisiuni"
- "Adăugați adresa camerei"
- "Oricine poate cere să se alăture camerei, dar un administrator sau moderator va trebui să accepte cererea."
- "Cereți să vă alăturați"
+ "Adăugați o adresă"
+ "Oricine se află în spațiile autorizate se poate alătura, dar toți ceilalți trebuie să solicite accesul."
+ "Toată lumea trebuie să solicite acces."
+ "Oricine în %1$s se poate alătura, dar toți ceilalți trebuie să solicite acces."
"Da, activați criptarea"
"Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei de la momentul la care au fost invitați sau de la momentul la care s-au alăturat camerei.
Nimeni în afară de membrii camerei nu va putea citi messaje. Acest lucru poate împiedica funcționarea corectă a boților și a punților.
@@ -125,22 +143,28 @@ Nu recomandăm activarea criptării pentru camerele pe care oricine le poate gă
"Odată activată, criptarea nu poate fi dezactivată."
"Criptare"
"Activați criptarea end-to-end"
- "Oricine poate găsi și alătura camerei"
- "Oricine"
- "Persoanele se pot alătura numai dacă invitate"
+ "Oricine se poate alătura."
+ "Alegeți membrii căror spații se pot alătura acestei camere fără invitație. %1$s"
+ "Gestionați spațiile"
+ "Doar persoanele invitate se pot alătura."
"Doar pe bază de invitație"
- "Acces la cameră"
+ "Acces"
+ "Oricine se află într-un spațiu autorizat poate participa."
+ "Oricine din %1$s se poate alătura."
"Membrii spațiului"
"Spațiile nu sunt momentan suportate."
- "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director."
+ "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public."
+ "Adresă"
"Permiteți găsirea acestei camere prin căutarea în directorul de camere publice al %1$s"
+ "Permiteți găsirea prin căutarea în directorul public."
"Vizibilă în directorul de camere publice"
- "Oricine"
"Cine poate citi mesajele anterioare"
"Doar pentru membri, de la momentul în care au fost invitați"
"Doar pentru membri, după selectarea acestei opțiuni"
"Adresele camerelor sunt modalități de a găsi și accesa camere. Acest lucru vă asigură, de asemenea, că puteți partaja cu ușurință camera dumneavoastră cu alte persoane.
Puteți alege să publicați camera în directorul public al camerelor serverului dumneavoastră."
"Publicare cameră"
+ "Adresele sunt o modalitate de a găsi și accesa camere și spații. Acest lucru asigură, de asemenea, că le puteți partaja cu ușurință cu alții."
+ "Vizibilitate"
"Securitate & confidențialitate"
diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
index 47de4c388c..e618bd8dc1 100644
--- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
@@ -63,7 +63,6 @@
"Профиль"
"Запросы на вступление"
"Роли и разрешения"
- "Название комнаты"
"Безопасность и конфиденциальность"
"Безопасность"
"Поделиться комнатой"
@@ -123,7 +122,7 @@
"Роли и разрешения"
"Добавить адрес"
"Каждый должен запросить доступ."
- "Попросить присоединиться"
+ "Присоединиться"
"Да, включить шифрование"
"Шифрование комнаты нельзя будет отключить, история сообщений будет видна только участникам комнаты с момента их приглашения или с момента присоединения к комнате.
Никто, кроме членов комнаты, не сможет читать сообщения. Это может помешать ботам и мостам работать корректно.
@@ -133,7 +132,7 @@
"Шифрование"
"Включить сквозное шифрование"
"Любой желающий может найти и присоединиться"
- "Любой"
+ "Публичный"
"Присоединиться могут только приглашенные люди."
"Только по приглашению"
"Доступ"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 477b419788..7cd14bc7a5 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -1,19 +1,21 @@
- "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári."
- "Adresa miestnosti"
+ "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári."
+ "Upraviť adresu"
"Pri aktualizácii nastavenia oznámenia došlo k chybe."
"Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v niektorých miestnostiach nemusíte dostať upozornenie."
"Ankety"
- "Iba správcovia"
+ "Správca"
"Zakázať ľudí"
"Odstrániť správy"
- "Pozvite ľudí a prijmite žiadosti o pripojenie"
+ "Člen"
+ "Pozvať ľudí"
+ "Spravovať členov"
"Správy a obsah"
- "Správcovia a moderátori"
- "Odstrániť ľudí a odmietnuť žiadosti o pripojenie"
+ "Moderátor"
+ "Odstrániť ľudí"
"Zmeniť obrázok miestnosti"
- "Upraviť miestnosť"
+ "Upraviť podrobnosti"
"Zmeniť názov miestnosti"
"Zmeniť tému miestnosti"
"Odoslať správy"
@@ -40,7 +42,7 @@
"Zašifrované"
"Nešifrované"
"Verejná miestnosť"
- "Upraviť miestnosť"
+ "Upraviť podrobnosti"
"Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť."
"Nepodarilo sa aktualizovať miestnosť"
"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."
@@ -61,7 +63,6 @@
"Profil"
"Žiadosti o vstup"
"Roly a povolenia"
- "Názov miestnosti"
"Bezpečnosť a súkromie"
"Bezpečnosť"
"Zdieľať miestnosť"
@@ -69,6 +70,13 @@
"Téma"
"Aktualizácia miestnosti…"
"Neexistujú žiadni zablokovaní používatelia."
+
+ - "%1$d zakázaný"
+ - "%1$d zakázaní"
+ - "%1$d zakázaných"
+
+ "Skontrolujte preklepy alebo skúste nové vyhľadávanie"
+ "Žiadne výsledky pre „%1$s“"
- "%1$d osoba"
- "%1$d osoby"
@@ -81,8 +89,14 @@
"Zrušiť zákaz prístupu do miestnosti"
"Zakázaní"
"Členovia"
- "Iba správcovia"
- "Správcovia a moderátori"
+
+ - "%1$d pozvaný"
+ - "%1$d pozvaní"
+ - "%1$d pozvaných"
+
+ "Čaká na schválenie"
+ "Správca"
+ "Moderátor"
"Vlastník"
"Členovia miestnosti"
"Zrušenie zákazu %1$s"
@@ -109,14 +123,15 @@
"Správy a obsah"
"Moderátori"
"Vlastníci"
+ "Povolenia"
"Obnoviť povolenia"
"Po obnovení oprávnení prídete o aktuálne nastavenia."
"Obnoviť oprávnenia?"
"Roly"
"Podrobnosti o miestnosti"
"Roly a povolenia"
- "Pridať adresu miestnosti"
- "Ktokoľvek môže požiadať o pripojenie do miestnosti, ale správca alebo moderátor bude musieť žiadosť prijať."
+ "Pridať adresu"
+ "Všetci musia požiadať o prístup."
"Požiadať o pripojenie"
"Áno, povoliť šifrovanie"
"Po aktivácii nie je možné zakázať šifrovanie pre miestnosť. História správ bude viditeľná len pre členov miestnosti, odkedy boli pozvaní alebo keď vstúpili do miestnosti.
@@ -126,17 +141,22 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p
"Po zapnutí už šifrovanie nie je možné vypnúť."
"Šifrovanie"
"Povoliť end-to-end šifrovanie"
- "Ktokoľvek môže nájsť a pripojiť sa"
+ "Pripojiť sa môže ktokoľvek."
"Ktokoľvek"
- "Ľudia sa môžu pripojiť len vtedy, ak sú pozvaní"
+ "Vyberte, ktorých členovia priestorov sa môžu pripojiť k tejto miestnosti bez pozvánky. %1$s"
+ "Spravovať priestory"
+ "Pripojiť sa môžu iba pozvaní ľudia."
"Iba na pozvánku"
- "Prístup do miestnosti"
+ "Prístup"
+ "Ktokoľvek v povolených priestoroch sa môže pripojiť."
+ "Ktokoľvek v %1$s sa môže pripojiť."
"Členovia priestoru"
"Priestory momentálne nie sú podporované"
- "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári."
- "Adresa miestnosti"
+ "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári."
+ "Adresa"
"Umožniť vyhľadanie tejto miestnosti v adresári verejných miestností %1$s"
- "Viditeľné v adresári verejných miestností"
+ "Umožniť nájdenie vyhľadávaním vo verejnom adresári."
+ "Viditeľné vo verejnom adresári"
"Ktokoľvek"
"Kto môže čítať históriu"
"Len pre členov, odkedy boli pozvaní"
@@ -144,6 +164,7 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p
"Adresy miestností predstavujú spôsoby, ako nájsť a získať prístup k miestnostiam. To tiež zaisťuje, že môžete jednoducho zdieľať svoju miestnosť s ostatnými.
Môžete sa rozhodnúť zverejniť svoju miestnosť v adresári verejných miestností vášho domovského servera."
"Zverejnenie miestnosti"
- "Viditeľnosť miestnosti"
+ "Adresy sú spôsob, ako nájsť a získať prístup do miestností a priestorov. To tiež zabezpečuje, že ich môžete jednoducho zdieľať s ostatnými."
+ "Viditeľnosť"
"Bezpečnosť a súkromie"
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 2dc590c2f4..0d7ade3f39 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -59,7 +59,6 @@
"Profil"
"Begäran om att gå med"
"Roller och behörigheter"
- "Rumsnamn"
"Säkerhet och sekretess"
"Säkerhet"
"Dela rum"
@@ -133,7 +132,6 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit
"Du behöver en rumsadress för att göra den synlig i katalogen."
"Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s"
"Synlig i katalogen för offentliga rum"
- "Vem som helst"
"Vem kan läsa historik"
"Endast medlemmar sedan de bjöds in"
"Endast medlemmar sedan det här alternativet har valts"
diff --git a/features/roomdetails/impl/src/main/res/values-tr/translations.xml b/features/roomdetails/impl/src/main/res/values-tr/translations.xml
index 01cac3a6cb..ab6c02fdf9 100644
--- a/features/roomdetails/impl/src/main/res/values-tr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-tr/translations.xml
@@ -55,7 +55,6 @@
"Profil"
"Katılma istekleri"
"Roller ve izinler"
- "Oda adı"
"Güvenlik ve gizlilik"
"Güvenlik"
"Oda paylaş"
@@ -106,7 +105,7 @@
"Roller ve izinler"
"Oda adresi ekle"
"Herkes odaya katılma isteğinde bulunabilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekir."
- "Katılmak için sor"
+ "Katılma isteği gönder"
"Evet, şifrelemeyi etkinleştir"
"Etkinleştirildikten sonra, bir oda için şifreleme devre dışı bırakılamaz, Mesaj geçmişi yalnızca davet edildiklerinden veya odaya katıldıklarından beri oda üyeleri için görünür olacaktır.
Oda üyeleri dışında hiç kimse mesajları okuyamayacaktır. Bu, botların ve köprülerin düzgün çalışmasını engelleyebilir.
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 57a604bfe8..65e424398d 100644
--- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
@@ -59,7 +59,6 @@
"Профіль"
"Запити на приєднання"
"Ролі та дозволи"
- "Назва кімнати"
"Безпека й приватність"
"Безпека"
"Поділитися кімнатою"
@@ -115,7 +114,7 @@
"Ролі та дозволи"
"Додати адресу кімнати"
"Будь-хто може надіслати запит приєднатися до кімнати, але адміністратор або модератор повинні прийняти запит."
- "Запросити приєднатися"
+ "Запит на приєднання"
"Так, увімкнути шифрування"
"Після ввімкнення шифрування кімнати, його неможливо вимкнути, історію повідомлень бачитимуть лише учасники кімнати, яких було запрошено або які приєдналися до кімнати.
Ніхто, крім учасників кімнати, не зможе прочитати повідомлення. Це може перешкоджати коректній роботі ботів і мостів.
@@ -125,7 +124,7 @@
"Шифрування"
"Увімкнути наскрізне шифрування"
"Будь-хто може знайти та приєднатися."
- "Кожний"
+ "Будь-хто"
"Люди можуть приєднатися, лише якщо їх запросили"
"Лише запрошені"
"Доступ до кімнати"
@@ -135,7 +134,7 @@
"Адреса кімнати"
"Дозвольте, щоб цю кімнату можна було знайти за допомогою пошуку в каталозі загальнодоступних кімнат %1$s "
"Видима в каталозі загальнодоступних кімнат"
- "Кожний"
+ "Будь-хто"
"Хто може читати історію"
"Лише учасники з моменту запрошення"
"Лише учасники після вибору цього параметра"
diff --git a/features/roomdetails/impl/src/main/res/values-ur/translations.xml b/features/roomdetails/impl/src/main/res/values-ur/translations.xml
index 524afe515d..3715bb91ff 100644
--- a/features/roomdetails/impl/src/main/res/values-ur/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ur/translations.xml
@@ -51,7 +51,6 @@
"مثبوتہ پیغامات"
"نمایہ"
"کردارہا اور اجازتیں"
- "کمرے کا نام"
"حفاظت"
"کمرے کا اشتراک کریں"
"کمرے کی معلومات"
diff --git a/features/roomdetails/impl/src/main/res/values-uz/translations.xml b/features/roomdetails/impl/src/main/res/values-uz/translations.xml
index 401d015735..d401c7e371 100644
--- a/features/roomdetails/impl/src/main/res/values-uz/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-uz/translations.xml
@@ -9,11 +9,12 @@
"Odamlarni taqiqlash"
"Xabarlarni olib tashlash"
"Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling"
+ "A’zolarni boshqarish"
"Xabarlar va kontent"
"Adminlar va moderatorlar"
"Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish"
"Xona avatarini oʻzgartirish"
- "Xonani tahrirlash"
+ "Tafsilotlarni tahrirlash"
"Xona nomini oʻzgartirish"
"Xona mavzusini almashtirish"
"Xabarlar yuborish"
@@ -40,7 +41,7 @@
"Shifrlangan"
"Shifrlanmagan"
"Jamoat xonasi"
- "Xonani tahrirlash"
+ "Tafsilotlarni 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."
@@ -59,7 +60,6 @@
"Profil"
"Qo‘shilish uchun so‘rovlar"
"Rollar va ruxsatlar"
- "Xona nomi"
"Xavfsizlik va maxfiylik"
"Xavfsizlik"
"Xonani baham ko\'ring"
@@ -114,7 +114,6 @@
"Rollar va ruxsatlar"
"Xona manzilini kiritish"
"Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak"
- "Qo‘shilishni so‘rang"
"Ha, shifrlashni yoqish"
"Yoqilgandan so‘ng, xona uchun shifrlashni o‘chirib bo‘lmaydi. Xabarlar tarixi faqat xona a’zolari taklif qilinganidan yoki xonaga qo‘shilganidan keyingi davrdan boshlab ko‘rinadi. Xona a’zolaridan tashqari hech kim xabarlarni o‘qiy olmaydi. Bu botlar va ko‘priklarning to‘g‘ri ishlashiga to‘sqinlik qilishi mumkin.
Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shifrlashni yoqishni tavsiya etmaymiz."
@@ -123,7 +122,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi
"Shifrlash"
"End-to-end shifrlashni yoqish"
"Istalgan kishi topishi va qo‘shilishi mumkin"
- "Har kim"
"Odamlar faqat taklif qilingan taqdirdagina qo‘shilishi mumkin"
"Faqat taklif qilish"
"Xonaga kirish huquqi"
@@ -132,7 +130,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi
"Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi."
"Bu xonani %1$s umumiy xonalar ro‘yxatidan qidirib topish imkoniyatini berish"
"Umumiy xona ro‘yxatida ko‘rinadi"
- "Har kim"
"Tarixni kim o‘qiy oladi"
"Taklif qilinganidan buyon faqat a’zolar"
"A’zolar faqat bu parametr tanlanganidan keyin"
diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
index 0332c0ffd5..8649a29ba6 100644
--- a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml
@@ -63,7 +63,6 @@
"個人檔案"
"請求加入"
"角色與權限"
- "聊天室名稱"
"安全與隱私"
"安全性"
"分享聊天室"
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 f925bdca2a..2568b550a3 100644
--- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml
@@ -61,7 +61,6 @@
"个人资料"
"申请加入"
"角色与权限"
- "聊天室名称"
"安全与隐私"
"安全"
"分享聊天室"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index c582f6f6fb..ca00571dd1 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -63,13 +63,13 @@
"Profile"
"Requests to join"
"Roles & permissions"
- "Room name"
+ "Name"
"Security & privacy"
"Security"
"Share room"
"Room info"
"Topic"
- "Updating room…"
+ "Updating details…"
"There are no banned users."
- "%1$d Banned"
@@ -129,8 +129,10 @@
"Room details"
"Roles & permissions"
"Add address"
+ "Anyone in authorised spaces can join, but everyone else must request access."
"Everyone must request access."
"Ask to join"
+ "Anyone in %1$s can join, but everyone else must request access."
"Yes, enable encryption"
"Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.
No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly.
@@ -141,12 +143,12 @@ We do not recommend enabling encryption for rooms that anyone can find and join.
"Enable end-to-end encryption"
"Anyone can join."
"Anyone"
- "Choose which spaces’ members can join this room without an invitation. %1$s"
+ "Choose which spaces’ members can join this room without an invitation. %1$s"
"Manage spaces"
"Only invited people can join."
"Invite only"
"Access"
- "Anyone in authorized spaces can join."
+ "Anyone in authorised spaces can join."
"Anyone in %1$s can join."
"Space members"
"Spaces are not currently supported"
@@ -155,10 +157,11 @@ We do not recommend enabling encryption for rooms that anyone can find and join.
"Allow for this room to be found by searching %1$s public room directory"
"Allow to be found by searching the public directory."
"Visible in public directory"
- "Anyone"
+ "Anyone (history is public)"
+ "Changes won\'t affect past messages, only new ones. %1$s"
"Who can read history"
- "Members only since they were invited"
- "Members only since selecting this option"
+ "Members since invited"
+ "Members (full history)"
"Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.
You can choose to publish your room in your homeserver public room directory."
"Room publishing"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt
index cd2af112a7..5042f942b6 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt
@@ -20,6 +20,7 @@ import io.element.android.features.messages.test.FakeMessagesEntryPoint
import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint
import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
+import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint
import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint
import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
@@ -63,6 +64,7 @@ class DefaultRoomDetailsEntryPointTest {
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(),
securityAndPrivacyEntryPoint = FakeSecurityAndPrivacyEntryPoint(),
+ roomDetailsEditEntryPoint = FakeRoomDetailsEditEntryPoint(),
)
}
val callback = object : RoomDetailsEntryPoint.Callback {
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt
index 5043aea88c..2d85744345 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt
@@ -13,8 +13,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
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.room.RoomMember
-import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.lambdaError
fun aRoom(
@@ -35,6 +36,7 @@ fun aRoom(
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
+ roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
@@ -42,29 +44,20 @@ fun aRoom(
activeMemberCount: Long = 1,
joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0,
- canInviteResult: (UserId) -> Result = { lambdaError() },
- canBanResult: (UserId) -> Result = { lambdaError() },
- canKickResult: (UserId) -> Result = { lambdaError() },
- canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() },
userDisplayNameResult: (UserId) -> Result = { lambdaError() },
userAvatarUrlResult: () -> Result = { lambdaError() },
- canUserJoinCallResult: (UserId) -> Result = { lambdaError() },
getUpdatedMemberResult: (UserId) -> Result = { lambdaError() },
userRoleResult: () -> Result = { lambdaError() },
setIsFavoriteResult: (Boolean) -> Result = { lambdaError() },
) = FakeBaseRoom(
sessionId = sessionId,
roomId = roomId,
- canInviteResult = canInviteResult,
- canBanResult = canBanResult,
- canKickResult = canKickResult,
- canSendStateResult = canSendStateResult,
userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult,
- canUserJoinCallResult = canUserJoinCallResult,
getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult,
+ roomPermissions = roomPermissions,
initialRoomInfo = aRoomInfo(
name = displayName,
rawName = rawName,
@@ -89,6 +82,7 @@ fun aJoinedRoom(
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
+ roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
@@ -97,17 +91,12 @@ fun aJoinedRoom(
joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
- canInviteResult: (UserId) -> Result = { lambdaError() },
- canBanResult: (UserId) -> Result = { lambdaError() },
- canKickResult: (UserId) -> Result = { lambdaError() },
- canSendStateResult: (UserId, StateEventType) -> Result