Merge branch 'release/25.12.0'

This commit is contained in:
Jorge Martín 2025-12-03 10:41:56 +01:00
commit 386bd11156
503 changed files with 6837 additions and 3111 deletions

View file

@ -25,7 +25,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View file

@ -27,7 +27,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-enterprise-{0}-{1}', matrix.variant, github.sha) || format('build-enterprise-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View file

@ -9,7 +9,7 @@ jobs:
# Skip in forks, it doesn't work even with the fallback token
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
with:

View file

@ -11,7 +11,7 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'

View file

@ -23,7 +23,7 @@ jobs:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -62,7 +62,7 @@ jobs:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
# Ensure we are building the branch and not the branch after being merged on develop

View file

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:

View file

@ -60,7 +60,7 @@ jobs:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:

View file

@ -17,7 +17,7 @@ jobs:
name: Search for forbidden patterns
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
@ -33,7 +33,7 @@ jobs:
name: Search for invalid screenshot files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
@ -45,7 +45,7 @@ jobs:
name: Search for invalid dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:
@ -71,7 +71,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -111,7 +111,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -144,7 +144,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -188,7 +188,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -228,7 +228,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -268,7 +268,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-knit-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-knit-develop-{0}', github.sha) || format('check-knit-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -299,7 +299,7 @@ jobs:
name: Check shell scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Run shellcheck
uses: ludeeus/action-shellcheck@2.0.0
with:
@ -311,7 +311,7 @@ jobs:
needs: [konsist, lint, ktlint, detekt]
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View file

@ -18,7 +18,7 @@ jobs:
group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:
@ -52,7 +52,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
@ -87,7 +87,7 @@ jobs:
group: ${{ format('build-release-main-fdroid-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:

View file

@ -22,7 +22,7 @@ jobs:
group: ${{ format('sonar-{0}', github.ref) }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View file

@ -11,7 +11,7 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Use JDK 21
uses: actions/setup-java@v5
with:
@ -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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy

View file

@ -12,7 +12,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
@ -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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings

View file

@ -1,3 +1,83 @@
Changes in Element X v25.11.3
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.11.3 -->
## What's Changed
### 🙌 Improvements
* Improve rendering notification for multi account by @bmarty in https://github.com/element-hq/element-x-android/pull/5645
* Change : roles and permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5685
* Improve account provider selection during the login flow by @bmarty in https://github.com/element-hq/element-x-android/pull/5692
* Let notifications use avatar fallback. by @bmarty in https://github.com/element-hq/element-x-android/pull/5721
* Changes : member list improvements by @ganfra in https://github.com/element-hq/element-x-android/pull/5728
### 🐛 Bugfixes
* Do not use the bestDescription but the caption for images, when available by @bmarty in https://github.com/element-hq/element-x-android/pull/5684
* Add the user certificate if any when creating Matrix Client. by @bmarty in https://github.com/element-hq/element-x-android/pull/5686
* Ensure the form data are not lost when opening the log viewer. by @bmarty in https://github.com/element-hq/element-x-android/pull/5695
* Fix password flow when using a login link by @bmarty in https://github.com/element-hq/element-x-android/pull/5693
* Fix layout issue in text composer by @bmarty in https://github.com/element-hq/element-x-android/pull/5710
* Fix navigation stack overflow when sharing media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5724
* Notification robustness by @bmarty in https://github.com/element-hq/element-x-android/pull/5726
* Send read receipts using the current timeline, not the live timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5731
* Render Owner in the horizontal list when editing Admins. by @bmarty in https://github.com/element-hq/element-x-android/pull/5736
* Stop overriding the homeserver when restoring a `Client` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5753
* Revert "Stop overriding the homeserver when restoring a `Client`" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5754
* Try fixing forced dark mode issues on MIUI on Android 10 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5708
* Fix crash at startup by @bmarty in https://github.com/element-hq/element-x-android/pull/5761
* Fix null pointer exception on room notification settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/5758
* Fix crash when viewing Pinned events by @bmarty in https://github.com/element-hq/element-x-android/pull/5764
* Fix crash when pressing back from the showkase Activity by @bmarty in https://github.com/element-hq/element-x-android/pull/5772
* Fix navigation issue once incoming share is handled by @bmarty in https://github.com/element-hq/element-x-android/pull/5773
* Fix crash in work manager by @bmarty in https://github.com/element-hq/element-x-android/pull/5768
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5704
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5747
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5782
### 🧱 Build
* Module cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5722
* Add `NIGHTLY` env for Sentry by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5779
### 🚧 In development 🚧
* Space : prepare Space Settings screen by @ganfra in https://github.com/element-hq/element-x-android/pull/5668
### Dependency upgrades
* fix(deps): update dependency androidx.core:core-splashscreen to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5687
* fix(deps): update dependency com.posthog:posthog-android to v3.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5696
* fix(deps): update metro to v0.7.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5697
* Update dependency org.matrix.rustcomponents:sdk-android to v25.11.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5716
* Update plugin ktlint to v14 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5713
* Update plugin dependencycheck to v12.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5717
* Update dependency org.maplibre.gl:android-sdk to v12.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5714
* Update dependency io.sentry:sentry-android to v8.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5720
* Update sqldelight to v2.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5730
* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5746
* fix(deps): update dependency com.google.firebase:firebase-bom to v34.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5737
* fix(deps): update metro to v0.7.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5752
* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.1.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5743
* Update dependency com.squareup.okhttp3:okhttp-bom to v5.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5757
* fix(deps): update dependency com.pinterest.ktlint:ktlint-cli to v1.8.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5738
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5762
* fix(deps): update dependencyanalysis to v3.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5776
### Others
* Extract save change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5679
* Use the dedicated subdomain for the bug report URL by default by @benbz in https://github.com/element-hq/element-x-android/pull/5689
* Convert `ComposerAlertMolecule` to use alert levels. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5691
* Improve composer alert molecule by @bmarty in https://github.com/element-hq/element-x-android/pull/5701
* Code consistency around view event handling by @bmarty in https://github.com/element-hq/element-x-android/pull/5698
* Update copyright holders by @bmarty in https://github.com/element-hq/element-x-android/pull/5706
* Fix rendering notifications after receiving redundant push by @SpiritCroc in https://github.com/element-hq/element-x-android/pull/5711
* Fix push gateway with some push provider (Sunup/autopush) by @p1gp1g in https://github.com/element-hq/element-x-android/pull/5741
* Use new notification sound in release. by @bmarty in https://github.com/element-hq/element-x-android/pull/5748
* Fix issue on brand color override by @bmarty in https://github.com/element-hq/element-x-android/pull/5626
* Add media retention policy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5749
* Enable logging OkHttp traffic based on the current log level by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5750
* Remove unused `slidingSyncProxy` from DB. by @bmarty in https://github.com/element-hq/element-x-android/pull/5755
* Add some performance metrics for Sentry by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5760
## New Contributors
* @benbz made their first contribution in https://github.com/element-hq/element-x-android/pull/5689
* @kaylendog made their first contribution in https://github.com/element-hq/element-x-android/pull/5691
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.2...v25.11.3
Changes in Element X v25.11.2
=============================

View file

@ -129,12 +129,12 @@ android {
)
signingConfig = signingConfigs.getByName("debug")
postprocessing {
isRemoveUnusedCode = true
isObfuscate = false
isOptimizeCode = true
isRemoveUnusedResources = true
proguardFiles("proguard-rules.pro")
optimization {
enable = true
keepRules {
files.add(File(projectDir, "proguard-rules.pro"))
files.add(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
}
@ -152,10 +152,6 @@ android {
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")
postprocessing {
initWith(release.postprocessing)
}
firebaseAppDistribution {
artifactType = "APK"
// We upload the universal APK to fix this error:
@ -341,7 +337,7 @@ fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
it.toString()
}
}
val artifactsFile = reportingExtension.file("licensee/android$capitalizedVariantName/artifacts.json")
val artifactsFile = reportingExtension.baseDirectory.file("licensee/android$capitalizedVariantName/artifacts.json")
val copyArtifactsTask =
project.tasks.register<AssetCopyTask>("copy${capitalizedVariantName}LicenseeReportToAssets") {

View file

@ -41,8 +41,6 @@
static int windowAttachCount(android.view.View);
}
-keep class io.element.android.x.di.** { *; }
# Keep LogSessionId class and related classes (https://github.com/androidx/media/issues/2535)
-keep class android.media.metrics.LogSessionId { *; }
@ -51,3 +49,24 @@
# Keep Media3 classes that use reflection (https://github.com/androidx/media/issues/2535)
-keep class androidx.media3.** { *; }
-dontwarn android.media.metrics.**
# New rules after AGP 8.13.1 upgrade
-dontwarn androidx.window.extensions.WindowExtensions
-dontwarn androidx.window.extensions.WindowExtensionsProvider
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
-dontwarn androidx.window.extensions.layout.DisplayFeature
-dontwarn androidx.window.extensions.layout.FoldingFeature
-dontwarn androidx.window.extensions.layout.WindowLayoutComponent
-dontwarn androidx.window.extensions.layout.WindowLayoutInfo
-dontwarn androidx.window.sidecar.SidecarDeviceState
-dontwarn androidx.window.sidecar.SidecarDisplayFeature
-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback
-dontwarn androidx.window.sidecar.SidecarInterface
-dontwarn androidx.window.sidecar.SidecarProvider
-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.**

View file

@ -10,6 +10,7 @@ package io.element.android.x.intent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@ -32,10 +33,12 @@ class DefaultIntentProvider(
roomId: RoomId?,
threadId: ThreadId?,
eventId: EventId?,
extras: Bundle?,
): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri()
extras?.let(::putExtras)
}
}
}

View file

@ -39,7 +39,6 @@ import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
@ -84,6 +83,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@ -92,6 +92,7 @@ import io.element.android.libraries.push.api.notifications.conversations.Notific
import io.element.android.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
@ -108,6 +109,7 @@ import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
@ContributesNode(SessionScope::class)
@AssistedInject
@ -139,6 +141,7 @@ class LoggedInFlowNode(
private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@ -202,6 +205,7 @@ class LoggedInFlowNode(
}
lifecycle.subscribe(
onCreate = {
analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
@ -238,6 +242,7 @@ class LoggedInFlowNode(
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop()
}
)
setupSendingQueue()
@ -261,7 +266,7 @@ class LoggedInFlowNode(
data class Room(
val roomIdOrAlias: RoomIdOrAlias,
val serverNames: List<String> = emptyList(),
val trigger: JoinedRoom.Trigger? = null,
val trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(),
val targetId: UUID = UUID.randomUUID(),
@ -311,8 +316,13 @@ class LoggedInFlowNode(
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
backstack.push(
NavTarget.Room(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom)
)
)
}
override fun navigateToSettings() {
@ -361,7 +371,7 @@ class LoggedInFlowNode(
val target = NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters,
trigger = JoinedRoom.Trigger.Timeline,
trigger = JoinedRoomAnalyticsEvent.Trigger.Timeline,
initialElement = RoomNavigationTarget.Root(data.eventId),
)
if (pushToBackstack) {
@ -475,7 +485,7 @@ class LoggedInFlowNode(
NavTarget.Room(
roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
trigger = JoinedRoom.Trigger.RoomDirectory,
trigger = JoinedRoomAnalyticsEvent.Trigger.RoomDirectory,
)
)
}
@ -515,7 +525,7 @@ class LoggedInFlowNode(
suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger? = null,
trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
): RoomFlowNode {

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ -43,6 +44,7 @@ class NotLoggedInFlowNode(
@Assisted plugins: List<Plugin>,
private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -57,6 +59,7 @@ class NotLoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun onDone()
}
private val callback: Callback = callback()
@ -64,6 +67,7 @@ class NotLoggedInFlowNode(
override fun onBuilt() {
super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn()
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@ -83,6 +87,10 @@ class NotLoggedInFlowNode(
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun onDone() {
callback.onDone()
}
}
loginEntryPoint.createNode(
parentNode = this,

View file

@ -62,6 +62,10 @@ import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -86,6 +90,8 @@ class RootFlowNode(
private val oidcActionFlow: OidcActionFlow,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
@ -95,6 +101,7 @@ class RootFlowNode(
plugins = plugins
) {
override fun onBuilt() {
analyticsColdStartWatcher.start()
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt()
observeNavState()
@ -243,6 +250,10 @@ class RootFlowNode(
override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport)
}
override fun onDone() {
backstack.pop()
}
}
val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params,
@ -306,7 +317,13 @@ class RootFlowNode(
suspend fun handleIntent(intent: Intent) {
val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
}
navigateTo(resolvedIntent.deeplinkData)
}
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)

View file

@ -19,10 +19,10 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.active
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
@ -48,6 +48,10 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
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.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@ -56,10 +60,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
import io.element.android.libraries.matrix.api.room.JoinedRoom as JoinedRoomInstance
@ContributesNode(SessionScope::class)
@AssistedInject
@ -70,9 +77,17 @@ class RoomFlowNode(
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val membershipObserver: RoomMembershipObserver,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Loading,
initialElement = run {
val joinedRoom = (plugins.filterIsInstance<Inputs>().first().initialElement as? RoomNavigationTarget.Root)?.joinedRoom
if (joinedRoom != null) {
NavTarget.JoinedRoom(joinedRoom)
} else {
NavTarget.Loading
}
},
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@ -82,7 +97,7 @@ class RoomFlowNode(
val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>,
val serverNames: List<String>,
val trigger: Optional<JoinedRoom.Trigger>,
val trigger: Optional<JoinedRoomAnalyticsEvent.Trigger>,
val initialElement: RoomNavigationTarget,
) : NodeInputs
@ -99,15 +114,23 @@ class RoomFlowNode(
data class JoinRoom(
val roomId: RoomId,
val serverNames: List<String>,
val trigger: im.vector.app.features.analytics.plan.JoinedRoom.Trigger,
val trigger: JoinedRoomAnalyticsEvent.Trigger,
) : NavTarget
@Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget
data class JoinedRoom(
val roomId: RoomId,
@IgnoredOnParcel val joinedRoom: JoinedRoomInstance? = null,
) : NavTarget {
constructor(joinedRoom: JoinedRoomInstance) : this(joinedRoom.roomId, joinedRoom)
}
}
override fun onBuilt() {
super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId()
}
@ -125,7 +148,9 @@ class RoomFlowNode(
}
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val roomInfoFlow = client.getRoomInfoFlow(roomId)
val joinedRoom = (inputs.initialElement as? RoomNavigationTarget.Root)?.joinedRoom
val roomInfoFlow = joinedRoom?.roomInfoFlow?.map { Optional.of(it) }
?: client.getRoomInfoFlow(roomId)
// This observes the local membership changes for the room
val membershipUpdateFlow = membershipObserver.updates
@ -141,6 +166,11 @@ class RoomFlowNode(
currentMembershipFlow.onEach { (previousMembership, membership) ->
Timber.d("Room membership: $membership")
if (membership == CurrentUserMembership.JOINED) {
val currentNavTarget = backstack.active?.key?.navTarget
if (currentNavTarget is NavTarget.JoinedRoom && currentNavTarget.roomId == roomId) {
Timber.d("Already in JoinedRoom $roomId, do nothing")
return@onEach
}
backstack.newRoot(NavTarget.JoinedRoom(roomId))
} else {
val leavingFromCurrentDevice =
@ -155,7 +185,7 @@ class RoomFlowNode(
NavTarget.JoinRoom(
roomId = roomId,
serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
trigger = inputs.trigger.getOrNull() ?: JoinedRoomAnalyticsEvent.Trigger.Invite,
)
)
}
@ -201,7 +231,8 @@ class RoomFlowNode(
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs(
roomId = navTarget.roomId,
initialElement = inputs.initialElement
initialElement = inputs.initialElement,
joinedRoom = navTarget.joinedRoom,
)
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
}

View file

@ -10,12 +10,15 @@ package io.element.android.appnav.room
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Root(
val eventId: EventId? = null,
@IgnoredOnParcel val joinedRoom: JoinedRoom? = null,
) : RoomNavigationTarget
@Parcelize

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
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.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged
@ -63,11 +64,12 @@ class JoinedRoomFlowNode(
) {
data class Inputs(
val roomId: RoomId,
val joinedRoom: JoinedRoom?,
val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId, inputs.joinedRoom)
sealed interface NavTarget : Parcelable {
@Parcelize

View file

@ -45,6 +45,11 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
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.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
@ -66,6 +71,7 @@ class JoinedRoomLoadedFlowNode(
private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder,
private val analyticsService: AnalyticsService,
roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
@ -93,6 +99,8 @@ class JoinedRoomLoadedFlowNode(
init {
lifecycle.subscribe(
onCreate = {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(LoadMessagesUi, parent)
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
@ -100,6 +108,7 @@ class JoinedRoomLoadedFlowNode(
trackVisitedRoom()
},
onResume = {
analyticsService.finishLongRunningTransaction(LoadJoinedRoomFlow)
sessionCoroutineScope.launch {
inputs.room.subscribeToSync()
}

View file

@ -34,6 +34,7 @@ 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.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@ -123,6 +124,7 @@ class JoinedRoomLoadedFlowNodeTest {
roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder,
analyticsService = FakeAnalyticsService(),
)
@Test

View file

@ -23,6 +23,19 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoadingBaseRoomStateFlowFactoryTest {
@Test
fun `flow should emit only Loaded when we already pass a JoinedRoom`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
val matrixClient = FakeMatrixClient(A_SESSION_ID)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = room)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
ensureAllEventsConsumed()
}
}
@Test
fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
@ -31,7 +44,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
}
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(this, A_ROOM_ID)
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
@ -45,7 +58,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(this, A_ROOM_ID)
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room)
@ -60,7 +73,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(this, A_ROOM_ID)
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.27")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.28")
detektPlugins(project(":tests:detekt-rules"))
}

View file

@ -0,0 +1,6 @@
Main changes in this version:
- Improve the room security and privacy screens.
- Better room list sorting.
- Fixed crashes when recording long voice messages.
- Improved the UX when opening a room from the room list.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item4">"Присъединете се към обществени пространства"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Visualizza gli spazi che hai creato o a cui partecipi"</string>
<string name="screen_space_announcement_item2">"Accetta o rifiuta gli inviti agli spazi"</string>
<string name="screen_space_announcement_item3">"Scopri tutte le stanze a cui puoi partecipare nei tuoi spazi"</string>
<string name="screen_space_announcement_item4">"Unisciti agli spazi pubblici"</string>
<string name="screen_space_announcement_item5">"Lascia tutti gli spazi a cui ti sei unito"</string>
<string name="screen_space_announcement_notice">"A breve saranno disponibili le funzionalità di filtraggio, creazione e gestione degli spazi."</string>
<string name="screen_space_announcement_subtitle">"Benvenuti alla versione beta degli Spazi! Con questa prima versione potrete:"</string>
<string name="screen_space_announcement_title">"Ti presentiamo gli Spazi"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Visualizar espaços que criou ou entrou"</string>
<string name="screen_space_announcement_item2">"Aceitar ou recusar convites aos espaços"</string>
<string name="screen_space_announcement_item3">"Descobrir quaisquer salas que você pode entrar nos espaços"</string>
<string name="screen_space_announcement_item4">"Entrar espaços públicos"</string>
<string name="screen_space_announcement_item5">"Sair de quaisquer espaços que entrou"</string>
<string name="screen_space_announcement_notice">"Filtrar, criar, e gerenciar espaços virão em breve."</string>
<string name="screen_space_announcement_subtitle">"Boas-vindas à versão beta dos Espaços! Com essa primeira versão, você pode:"</string>
<string name="screen_space_announcement_title">"Apresentando Espaços"</string>
</resources>

View file

@ -2,4 +2,10 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"查看您创建或加入的空间"</string>
<string name="screen_space_announcement_item2">"接受或拒绝空间邀请"</string>
<string name="screen_space_announcement_item3">"发现您可以加入空间的所有房间"</string>
<string name="screen_space_announcement_item4">"加入公共空间"</string>
<string name="screen_space_announcement_item5">"离开你加入的所有空间"</string>
<string name="screen_space_announcement_notice">"筛选、创建及管理空间功能即将上线。"</string>
<string name="screen_space_announcement_subtitle">"欢迎使用 Spaces 测试版!使用首个版本,您可以:"</string>
<string name="screen_space_announcement_title">"Spaces 简介"</string>
</resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Uusi huone"</string>
<string name="screen_create_room_add_people_title">"Kutsu ihmisiä"</string>
<string name="screen_create_room_add_people_title">"Kutsu henkilöitä"</string>
<string name="screen_create_room_error_creating_room">"Huoneen luomisessa tapahtui virhe"</string>
<string name="screen_create_room_private_option_description">"Vain kutsutut henkilöt pääsevät tähän huoneeseen. Kaikki viestit ovat päästä päähän salattuja."</string>
<string name="screen_create_room_private_option_title">"Yksityinen huone"</string>

View file

@ -14,6 +14,7 @@ Você pode mudar isso a qualquer momento nas configurações da sala."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Pedir para entrar"</string>
<string name="screen_create_room_room_address_section_footer">"Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala."</string>
<string name="screen_create_room_room_address_section_title">"Endereço da sala"</string>
<string name="screen_create_room_room_name_label">"Nome da sala"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_title">"Criar uma sala"</string>

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
fun createNode(
@ -22,7 +23,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId)
fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom()
fun navigateToSettings()
fun navigateToSetUpRecovery()

View file

@ -14,6 +14,8 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@ -41,20 +43,33 @@ import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class)
@AssistedInject
@ -71,6 +86,7 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -150,9 +166,58 @@ class HomeFlowNode(
return node(buildContext) { modifier ->
val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current)
val loadingJoinedRoomJob = remember { mutableStateOf<AsyncData<Job>>(AsyncData.Uninitialized) }
if (loadingJoinedRoomJob.value.isLoading()) {
DelayedVisibility(duration = 400.milliseconds) {
ProgressDialog(
onDismissRequest = {
loadingJoinedRoomJob.value.dataOrNull()?.cancel()
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
)
}
}
fun navigateToRoom(
roomId: RoomId,
) {
if (!loadingJoinedRoomJob.value.isUninitialized()) {
Timber.w("Already loading a room, ignoring navigateToRoom for $roomId")
return
}
val job = sessionCoroutineScope.launch {
runCatchingExceptions {
matrixClient.getJoinedRoom(roomId)
}.fold(
onSuccess = { joinedRoom ->
if (isActive) {
callback.navigateToRoom(roomId, joinedRoom)
loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
},
onFailure = {
// If the operation wasn't cancelled, navigate without the room, using the room id
if (it !is CancellationException) {
callback.navigateToRoom(roomId, null)
}
loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
)
}
loadingJoinedRoomJob.value = AsyncData.Loading(job)
}
HomeView(
homeState = state,
onRoomClick = callback::navigateToRoom,
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@ -165,7 +230,7 @@ class HomeFlowNode(
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = callback::navigateToRoom,
onAcceptInviteSuccess = ::navigateToRoom,
onDeclineInviteSuccess = { },
modifier = Modifier
)

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
@ -120,6 +121,7 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
latestEvent = room.latestEvent,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
@ -136,6 +138,7 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
latestEvent = room.latestEvent,
timestamp = null,
isHighlighted = room.isHighlighted
)
@ -211,6 +214,7 @@ private fun RoomSummaryScaffoldRow(
@Composable
private fun NameAndTimestampRow(
name: String?,
latestEvent: LatestEvent,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
@ -219,16 +223,42 @@ private fun NameAndTimestampRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
// Name
Text(
Row(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
verticalAlignment = Alignment.CenterVertically,
) {
// Name
Text(
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
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(
text = timestamp ?: "",
@ -274,21 +304,41 @@ private fun MessagePreviewAndIndicatorRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp)
) {
val messagePreview = if (room.isTombstoned) {
stringResource(R.string.screen_roomlist_tombstoned_room_description)
if (room.isTombstoned) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.screen_roomlist_tombstoned_room_description),
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else {
room.lastMessage.orEmpty()
if (room.latestEvent is LatestEvent.Error) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.common_message_failed_to_send),
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else {
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Call and unread
Row(

View file

@ -9,15 +9,17 @@
package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -26,7 +28,7 @@ import kotlinx.collections.immutable.toImmutableList
@Inject
class RoomListRoomSummaryFactory(
private val dateFormatter: DateFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
private val roomLatestEventFormatter: RoomLatestEventFormatter,
) {
fun create(roomSummary: RoomSummary): RoomListRoomSummary {
val roomInfo = roomSummary.info
@ -40,13 +42,11 @@ class RoomListRoomSummaryFactory(
numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
isMarkedUnread = roomInfo.isMarkedUnread,
timestamp = dateFormatter.format(
timestamp = roomSummary.lastMessageTimestamp,
timestamp = roomSummary.latestEventTimestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
lastMessage = roomSummary.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
}.orEmpty(),
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall,
@ -73,4 +73,28 @@ class RoomListRoomSummaryFactory(
isSpace = roomInfo.isSpace,
)
}
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
return when (latestEvent) {
is LatestEventValue.None -> {
LatestEvent.None
}
is LatestEventValue.Local -> {
if (latestEvent.isSending) {
val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty()
LatestEvent.Sending(
content = content,
)
} else {
LatestEvent.Error
}
}
is LatestEventValue.Remote -> {
val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty()
LatestEvent.Synced(
content = content,
)
}
}
}
}

View file

@ -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.home.impl.model
import androidx.compose.runtime.Immutable
@Immutable
sealed interface LatestEvent {
data object None : LatestEvent
data class Synced(
val content: CharSequence?,
) : LatestEvent
data class Sending(
val content: CharSequence?,
) : LatestEvent
data object Error : LatestEvent
fun content(): CharSequence? {
return when (this) {
is None -> null
is Synced -> content
is Sending -> content
is Error -> null
}
}
}

View file

@ -29,7 +29,7 @@ data class RoomListRoomSummary(
val numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean,
val timestamp: String?,
val lastMessage: CharSequence?,
val latestEvent: LatestEvent,
val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,

View file

@ -25,12 +25,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(),
aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary(latestEvent = LatestEvent.None),
aRoomListRoomSummary(
name = "A very long room name that should be truncated",
lastMessage = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
latestEvent = LatestEvent.Synced(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
),
timestamp = "yesterday",
numberOfUnreadMessages = 1,
),
@ -44,7 +46,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
listOf(
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "No activity" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("No activity" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
@ -52,7 +54,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New messages" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 0,
@ -60,7 +62,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages, mentions" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New messages, mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 1,
@ -68,7 +70,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New mentions" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 1,
@ -127,6 +129,10 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
isTombstoned = true,
)
),
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
)
).flatten()
}
@ -148,8 +154,8 @@ internal fun aRoomListRoomSummary(
numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" },
latestEvent: LatestEvent = LatestEvent.Synced("Last message"),
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
@ -171,7 +177,7 @@ internal fun aRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
lastMessage = lastMessage,
latestEvent = latestEvent,
avatarData = avatarData,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@ -86,6 +87,7 @@ class RoomListPresenter(
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@ -236,6 +238,8 @@ class RoomListPresenter(
)
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
coldStartWatcher.onRoomListVisible()
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,

View file

@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.model.aRoomListRoomSummary
@ -88,7 +89,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room",
numberOfUnreadMessages = 1,
timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines",
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",
),
@ -96,7 +97,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room#2",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A short message",
latestEvent = LatestEvent.Synced("A short message"),
avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
id = "!roomId2:domain",
),
@ -119,7 +120,7 @@ internal fun generateRoomListRoomSummaryList(
name = "Room#$index",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A message",
latestEvent = LatestEvent.Synced("A message"),
avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem),
id = "!roomId$index:domain",
)

View file

@ -6,6 +6,7 @@
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان."</string>
<string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string>
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
<string name="confirm_recovery_key_banner_message">"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیام‌هایتان حفظ شود ."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
<string name="confirm_recovery_key_banner_title">"ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده"</string>
<string name="full_screen_intent_banner_title">"بهبود تجریهٔ تماستان"</string>

View file

@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"Disabilita l\'ottimizzazione della batteria per questa app, per assicurarti che tutte le notifiche vengano ricevute."</string>
<string name="banner_battery_optimization_submit_android">"Disabilita l\'ottimizzazione"</string>
<string name="banner_battery_optimization_title_android">"Le notifiche non arrivano?"</string>
<string name="banner_new_sound_message">"Il ping delle notifiche è stato aggiornato: ora è più chiaro, più rapido e meno fastidioso."</string>
<string name="banner_new_sound_title">"Abbiamo rinnovato i tuoi suoni"</string>
<string name="banner_set_up_recovery_content">"Recupera la tua identità crittografica e la cronologia dei messaggi con una chiave di recupero se hai perso tutti i tuoi dispositivi."</string>
<string name="banner_set_up_recovery_submit">"Configura il recupero"</string>
<string name="banner_set_up_recovery_title">"Configura il ripristino"</string>

View file

@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"Desative a otimização de bateria para este app, para que tenha certeza que todas as notificações sejam recebidas."</string>
<string name="banner_battery_optimization_submit_android">"Desativar otimização"</string>
<string name="banner_battery_optimization_title_android">"As notificações não chegam?"</string>
<string name="banner_new_sound_message">"O seu ping de notificação foi atualizado—mais suave, mais rápido, e menos disruptivo."</string>
<string name="banner_new_sound_title">"Recarregamos seus sons"</string>
<string name="banner_set_up_recovery_content">"Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação caso você perda todos os dispositivos existentes."</string>
<string name="banner_set_up_recovery_submit">"Configurar a recuperação"</string>
<string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</string>
@ -33,6 +35,7 @@ Por enquanto, você pode desmarcar os filtros para ver suas outras conversas"</s
<string name="screen_roomlist_filter_invites">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Você não tem nenhum convite pendente."</string>
<string name="screen_roomlist_filter_low_priority">"Baixa prioridade"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Você ainda não tem nenhuma conversa de baixa prioridade"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Você pode desmarcar filtros para ver suas outras conversas"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Você não tem conversas para esta seleção"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</string>

View file

@ -13,6 +13,7 @@
<string name="full_screen_intent_banner_message">"Muhim qoʻngʻiroqlarni oʻtkazib yubormasligingiz uchun telefoningiz qulflangan holatida toʻliq ekranli bildirishnomalarni korsatishga ruxsat beradigan qilib sozlamalaringizni oʻzgartiring."</string>
<string name="full_screen_intent_banner_title">"Qoʻngʻiroq tajribangizni yaxshilang"</string>
<string name="screen_home_tab_chats">"Suhbatlar"</string>
<string name="screen_home_tab_spaces">"Boshliqlar"</string>
<string name="screen_invites_decline_chat_message">"Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Taklifni rad etish"</string>
<string name="screen_invites_decline_direct_chat_message">"Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"</string>
@ -22,6 +23,7 @@
<string name="screen_migration_message">"Bu bir martalik jarayon, kutganingiz uchun rahmat."</string>
<string name="screen_migration_title">"Hisobingiz sozlanmoqda."</string>
<string name="screen_roomlist_a11y_create_message">"Yangi suhbat yoki xona yarating"</string>
<string name="screen_roomlist_clear_filters">"Filtrlarni tozalash"</string>
<string name="screen_roomlist_empty_message">"Kimgadir xabar yuborishdan boshlang."</string>
<string name="screen_roomlist_empty_title">"Hozircha chatlar yoq."</string>
<string name="screen_roomlist_filter_favourites">"Sevimlilar"</string>
@ -31,6 +33,7 @@ Hozircha, boshqa suhbatlaringizni korish uchun filtrlarni bekor qilishingiz m
<string name="screen_roomlist_filter_invites">"Takliflar"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sizda hech qanday kutilayotgan takliflar yoʻq."</string>
<string name="screen_roomlist_filter_low_priority">"Past darajali"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sizda hali past ustuvor chatlar yoʻq"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Boshqa suhbatlaringizni koʻrish uchun filtrlarni bekor qilishingiz mumkin"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sizda bu tanlov uchun chatlar yoq"</string>
<string name="screen_roomlist_filter_people">"Odamlar"</string>
@ -44,6 +47,7 @@ Sizda oʻqilmagan xabarlar yoʻq!"</string>
<string name="screen_roomlist_main_space_title">"Suhbatlar"</string>
<string name="screen_roomlist_mark_as_read">"Oʻqilgan deb belgilash"</string>
<string name="screen_roomlist_mark_as_unread">"Oʻqilmagan deb belgilash"</string>
<string name="screen_roomlist_tombstoned_room_description">"Bu xona yangilandi"</string>
<string name="session_verification_banner_message">"Siz yangi qurilmadan foydalanayotganga oxshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang."</string>
<string name="session_verification_banner_title">"Siz ekanligingizni tasdiqlang"</string>
</resources>

View file

@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"请关闭本应用的电池优化设置,确保不错过任何消息通知。"</string>
<string name="banner_battery_optimization_submit_android">"禁用优化"</string>
<string name="banner_battery_optimization_title_android">"通知未送达?"</string>
<string name="banner_new_sound_message">"您的通知提示音已升级 - 更清晰、更快速、干扰更少。"</string>
<string name="banner_new_sound_title">"我们已更新您的声音"</string>
<string name="banner_set_up_recovery_content">"生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"</string>
<string name="banner_set_up_recovery_submit">"设置恢复"</string>
<string name="banner_set_up_recovery_title">"设置恢复"</string>

View file

@ -13,17 +13,19 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultHomeEntryPointTest {
@Test
fun `test node builder`() {
fun `test node builder`() = runTest {
val entryPoint = DefaultHomeEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
HomeFlowNode(
@ -39,10 +41,11 @@ class DefaultHomeEntryPointTest {
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
)
}
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) = lambdaError()
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = lambdaError()

View file

@ -10,12 +10,13 @@ package io.element.android.features.home.impl.datasource
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" }
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
) = RoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter
roomLatestEventFormatter = roomLatestEventFormatter,
)

View file

@ -96,7 +96,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
lastMessage = "",
latestEvent = LatestEvent.Synced(""),
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,

View file

@ -35,8 +35,8 @@ import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -70,6 +70,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -638,7 +639,7 @@ class RoomListPresenterTest {
client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
@ -655,7 +656,7 @@ class RoomListPresenterTest {
roomListService = client.roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter,
roomLatestEventFormatter = roomLatestEventFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService,
@ -673,5 +674,6 @@ class RoomListPresenterTest {
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
)
}

View file

@ -170,7 +170,7 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString()).performClick()
rule.onNodeWithText(room0.latestEvent.content().toString()).performClick()
}
eventsRecorder.assertEmpty()
@ -192,7 +192,7 @@ class RoomListViewTest {
)
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString())
rule.onNodeWithText(room0.latestEvent.content().toString())
.performClick()
.performClick()
}
@ -214,7 +214,7 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString()).performTouchInput { longClick() }
rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
}

View file

@ -14,7 +14,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
@ -126,7 +126,7 @@ fun TestScope.createRoomListSearchPresenter(
roomListService = roomListService,
roomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = FakeDateFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
roomLatestEventFormatter = FakeRoomLatestEventFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
),

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_decline_and_block_block_user_option_description">"Siz bu foydalanuvchidan hech qanday xabar yoki xonaga taklif kormaysiz"</string>
<string name="screen_decline_and_block_block_user_option_title">"Foydalanuvchini bloklash"</string>
<string name="screen_decline_and_block_report_user_option_description">"Bu xona haqida hisobingiz provayderiga xabar bering."</string>
<string name="screen_decline_and_block_report_user_reason_placeholder">"Xabar berish sababini tushuntiring…"</string>
<string name="screen_decline_and_block_title">"Rad etish va bloklash"</string>
<string name="screen_invites_decline_chat_message">"Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Taklifni rad etish"</string>
@ -8,5 +11,8 @@
<string name="screen_invites_decline_direct_chat_title">"Chatni rad etish"</string>
<string name="screen_invites_empty_list">"Takliflar yo\'q"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) sizni taklif qildi"</string>
<string name="screen_join_room_decline_and_block_alert_confirmation">"Ha, rad etish va bloklash"</string>
<string name="screen_join_room_decline_and_block_alert_message">"Ushbu xonaga qoshilish taklifini rad etishga ishonchingiz komilmi? Bu %1$sning siz bilan boglanishiga yoki sizni xonalarga taklif qilishiga ham tosqinlik qiladi."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Taklifni rad etish va bloklash"</string>
<string name="screen_join_room_decline_and_block_button_title">"Rad etish va bloklash"</string>
</resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_by_message">"Sei stato bandito da questa stanza da %1$s."</string>
<string name="screen_join_room_ban_message">"Sei stato bandito da questa stanza"</string>
<string name="screen_join_room_ban_by_message">"Sei stato bannato da %1$s ."</string>
<string name="screen_join_room_ban_message">"Sei stato bannato"</string>
<string name="screen_join_room_ban_reason">"Motivo: %1$s"</string>
<string name="screen_join_room_cancel_knock_action">"Cancella richiesta"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sì, annulla"</string>
@ -11,10 +11,11 @@
<string name="screen_join_room_decline_and_block_alert_message">"Sei sicuro di voler rifiutare l\'invito a entrare in questa stanza? Ciò impedirà a %1$s di contattarti o invitarti nuovamente in una stanza."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Rifiuta invito e blocca"</string>
<string name="screen_join_room_decline_and_block_button_title">"Rifiuta e blocca"</string>
<string name="screen_join_room_fail_message">"L\'accesso alla stanza non è riuscito."</string>
<string name="screen_join_room_fail_reason">"Questa stanza è solo su invito o potrebbero esserci delle restrizioni all\'accesso al livello dello spazio."</string>
<string name="screen_join_room_forget_action">"Dimentica questa stanza"</string>
<string name="screen_join_room_invite_required_message">"Hai bisogno di un invito per entrare in questa stanza"</string>
<string name="screen_join_room_fail_message">"Partecipazione non riuscita"</string>
<string name="screen_join_room_fail_reason">"Devi essere invitato per partecipare o potrebbero esserci delle restrizioni di accesso."</string>
<string name="screen_join_room_forget_action">"Dimentica"</string>
<string name="screen_join_room_invite_required_message">"Per partecipare è necessario un invito"</string>
<string name="screen_join_room_invited_by">"Invitato da"</string>
<string name="screen_join_room_join_action">"Entra"</string>
<string name="screen_join_room_join_restricted_message">"Potrebbe essere necessario essere invitati o essere membro di uno spazio per partecipare."</string>
<string name="screen_join_room_knock_action">"Bussa per partecipare"</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_by_message">"Você foi banido desta sala por %1$s."</string>
<string name="screen_join_room_ban_message">"Você foi banido desta sala"</string>
<string name="screen_join_room_ban_by_message">"Você foi banido por %1$s."</string>
<string name="screen_join_room_ban_message">"Você foi banido"</string>
<string name="screen_join_room_ban_reason">"Motivo: %1$s."</string>
<string name="screen_join_room_cancel_knock_action">"Cancelar pedido"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sim, cancelar"</string>
@ -11,10 +11,11 @@
<string name="screen_join_room_decline_and_block_alert_message">"Tem certeza de que quer recusar o convite para entrar nesta sala? Isso também impedirá que %1$s entre em contato com você ou o convide para salas."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Recusar convite e bloquear"</string>
<string name="screen_join_room_decline_and_block_button_title">"Recusar e bloquear"</string>
<string name="screen_join_room_fail_message">"A entrada na sala falhou."</string>
<string name="screen_join_room_fail_reason">"Esta sala é apenas para convidados ou pode haver restrições de acesso a nível do espaço."</string>
<string name="screen_join_room_forget_action">"Esquecer esta sala"</string>
<string name="screen_join_room_invite_required_message">"Você precisa de um convite para entrar nesta sala"</string>
<string name="screen_join_room_fail_message">"Falha ao entrar"</string>
<string name="screen_join_room_fail_reason">"Você precisa ser convidado ou pode haver restrições ao acesso."</string>
<string name="screen_join_room_forget_action">"Esquecer"</string>
<string name="screen_join_room_invite_required_message">"Você precisa de um convite para entrar"</string>
<string name="screen_join_room_invited_by">"Convidado por"</string>
<string name="screen_join_room_join_action">"Entrar"</string>
<string name="screen_join_room_join_restricted_message">"Talvez você precise ser convidado ou ser membro de um espaço para participar."</string>
<string name="screen_join_room_knock_action">"Enviar solicitação para entrar"</string>

View file

@ -1,15 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_by_message">"Siz %1$s tomonidan ushbu xonadan ban qilingansiz."</string>
<string name="screen_join_room_ban_message">"Siz bu xonadan chetlashtirilgansiz"</string>
<string name="screen_join_room_ban_reason">"Sababi: %1$s ."</string>
<string name="screen_join_room_cancel_knock_action">"Sorovni bekor qilish"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Ha, bekor qiling"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Bu xonaga qoshilish sorovingizni bekor qilishni xohlayotganingizga ishonchingiz komilmi?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Qoshilish sorovini bekor qilish"</string>
<string name="screen_join_room_decline_and_block_alert_confirmation">"Ha, rad etish va bloklash"</string>
<string name="screen_join_room_decline_and_block_alert_message">"Ushbu xonaga qoshilish taklifini rad etishga ishonchingiz komilmi? Bu %1$sning siz bilan boglanishiga yoki sizni xonalarga taklif qilishiga ham tosqinlik qiladi."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Taklifni rad etish va bloklash"</string>
<string name="screen_join_room_decline_and_block_button_title">"Rad etish va bloklash"</string>
<string name="screen_join_room_fail_message">"Xonaga qoshilish amalga oshmadi"</string>
<string name="screen_join_room_fail_reason">"Bu xona faqat taklif etilganlar uchun yoki bu maydonga kirish huquqi cheklangan bolishi mumkin."</string>
<string name="screen_join_room_forget_action">"Bu xonani esdan chiqarish"</string>
<string name="screen_join_room_invite_required_message">"Bu xonaga kirish uchun taklifnoma kerak"</string>
<string name="screen_join_room_join_action">"Qo\'shilish"</string>
<string name="screen_join_room_join_restricted_message">"Qoshilish uchun sizga taklif kerak yoki siz maydonga azo bolishingiz kerak."</string>
<string name="screen_join_room_knock_action">"Qoʻshilish soʻrovini yuborish"</string>
<string name="screen_join_room_knock_message_characters_count">"Ruxsat etilgan belgilar: %1$d / %2$d"</string>
<string name="screen_join_room_knock_message_description">"Xabar (ixtiyoriy)"</string>
<string name="screen_join_room_knock_sent_description">"Agar sorovingiz qabul qilinsa, xonaga qoshilish taklifini olasiz."</string>
<string name="screen_join_room_knock_sent_title">"Qoshilish sorovi yuborildi"</string>
<string name="screen_join_room_loading_alert_message">"Xona korinishini namoyish eta olmadik. Bu tarmoq yoki server muammolari tufayli yuz bergan bolishi mumkin."</string>
<string name="screen_join_room_loading_alert_title">"Biz bu xonani oldindan korishni korsata olmadik "</string>
<string name="screen_join_room_space_not_supported_description">"%1$s hali maydon xizmatini qoʻllab-quvvatlamaydi. maydonga veb-sayt orqali kirishingiz mumkin."</string>
<string name="screen_join_room_space_not_supported_title">"Maydonlar hali qoʻllab-quvvatlanmaydi"</string>
<string name="screen_join_room_subtitle_knock">"Quyidagi tugmani bosing va xona administratoriga xabar beriladi. Ruxsat berilgandan soʻng suhbatga qoʻshilishingiz mumkin boʻladi."</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_by_message">"你被 %1$s 从此房间封禁。"</string>
<string name="screen_join_room_ban_message">"你已被此房间封禁"</string>
<string name="screen_join_room_ban_by_message">"您已被禁止访问%1$s。"</string>
<string name="screen_join_room_ban_message">"你已被禁止访问"</string>
<string name="screen_join_room_ban_reason">"理由:%1$s。"</string>
<string name="screen_join_room_cancel_knock_action">"取消请求"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"是的,取消"</string>
@ -11,10 +11,10 @@
<string name="screen_join_room_decline_and_block_alert_message">"您确定要拒绝加入此房间的邀请吗?这也将阻止%1$s 与您联系或邀请您加入房间。"</string>
<string name="screen_join_room_decline_and_block_alert_title">"拒绝邀请并屏蔽"</string>
<string name="screen_join_room_decline_and_block_button_title">"拒绝并屏蔽"</string>
<string name="screen_join_room_fail_message">"加入房间失败"</string>
<string name="screen_join_room_fail_reason">"要么此房间仅限受邀者,要么可能在空间层级有加入限制。"</string>
<string name="screen_join_room_forget_action">"忘记这个房间"</string>
<string name="screen_join_room_invite_required_message">"你需要邀请才能加入这个房间"</string>
<string name="screen_join_room_fail_message">"加入失败"</string>
<string name="screen_join_room_fail_reason">"您需要被邀请加入,否则可能会受到访问限制。"</string>
<string name="screen_join_room_forget_action">"忘记"</string>
<string name="screen_join_room_invite_required_message">"您需要邀请才能加入"</string>
<string name="screen_join_room_invited_by">"受邀于"</string>
<string name="screen_join_room_join_action">"加入"</string>
<string name="screen_join_room_join_restricted_message">"您可能需要受到邀请或成为某个空间的成员才能加入。"</string>

View file

@ -1,5 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Ha, hammasini qabul qiling"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Barcha qoshilish sorovlarini qabul qilishga ishonchingiz komilmi?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Barcha sorovlarni qabul qilish"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Hammasini qabul qiling"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Biz barcha sorovlarni qabul qila olmadik. Qayta urinib koʻrmoqchimisiz?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Barcha sorovlar qabul qilinmadi"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Qoshilish sorovi qabul qilinmoqda"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Biz bu sorovni qabul qila olmadik. Yana bir bor urinib korishni xohlaysizmi?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Sorovni qabul qilib bolmadi"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Qoshilish sorovi qabul qilinmoqda"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ha, rad eting va taqiqlang"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Siz %1$sʼni rad etib, taqiqlashni xohlayotganingizga ishonchingiz komilmi? Bu foydalanuvchi ushbu xonaga qayta kirish uchun ruxsat soray olmaydi."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Rad etish va kirishni taqiqlash"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Kirishni rad etish va taqiqlash"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ha, rad etish"</string>
<string name="screen_knock_requests_list_decline_alert_description">"%1$sning bu xonaga qoshilish sorovini rad etasizmi?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Kirishni rad etish"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Rad etish va taqiqlash"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Biz bu iltimosni rad etolmasdik. Yana bir bor urinib korishni xohlaysizmi?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Sorovni rad etib bolmadi"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Qoshilish sorovi rad etilayapti"</string>
<string name="screen_knock_requests_list_empty_state_description">"Kimdir xonaga qoshilishni soraganda, uning iltimosini shu yerda korishingiz mumkin."</string>
<string name="screen_knock_requests_list_empty_state_title">"Qoshilish sorovi kutilmayapti"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Qoshilish uchun sorovlar yuklanmoqda…"</string>
<string name="screen_knock_requests_list_title">"Qoshilish uchun sorovlar"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s + %2$d kishi bu xonaga qoshilmoqchi"</item>
<item quantity="other">"%1$s + %2$d kishi bu xonaga qoshilmoqchi"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Hammasini ko\'rish"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Qabul qiling"</string>
<string name="screen_room_single_knock_request_title">"%1$s bu xonaga qoshilmoqchi"</string>
<string name="screen_room_single_knock_request_view_button_title">"Ko\'rish"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Bu suhbatni tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu suhbat hammaga ochiq emas va siz taklifsiz qayta qoshila olmaysiz."</string>
<string name="leave_room_alert_empty_subtitle">"Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Siz bu yerda yagona odamsiz. Agar siz tark etsangiz, kelajakda hech kim qo\'shila olmaydi, jumladan siz ham."</string>
<string name="leave_room_alert_private_subtitle">"Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu xona ochiq emas va siz taklifsiz qayta qoshila olmaysiz."</string>
<string name="leave_room_alert_select_new_owner_action">"Egalarni tanlang"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Siz bu xonaning yagona egasisiz. Xonadan chiqishdan oldin egalikni boshqaga topshirishingiz kerak."</string>
<string name="leave_room_alert_select_new_owner_title">"Egalikni topshirish"</string>
<string name="leave_room_alert_subtitle">"Xonani tark etmoqchi ekanligingizga ishonchingiz komilmi?"</string>
</resources>

View file

@ -24,6 +24,14 @@
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"پین‌ها مطابق نیستند"</string>
<string name="screen_app_lock_signout_alert_message">"برای ادامه باید دوباره وارد شده و پینی جدید ایجاد کنید"</string>
<string name="screen_app_lock_signout_alert_title">"دارید خارج می‌شوید"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"شما %1$d تلاش برای باز کردن قفل دارید"</item>
<item quantity="other">"شما %1$d تلاش برای باز کردن قفل دارید"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"پین اشتباه است. شما %1$d شانس دیگر دارید"</item>
<item quantity="other">"پین اشتباه است. شما %1$d شانس دیگر دارید"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"استفاده از زیست‌سنجی"</string>
<string name="screen_app_lock_use_pin_android">"استفاده از پین"</string>
<string name="screen_signout_in_progress_dialog_content">"خارج شدن…"</string>

View file

@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrik autentifikatsiya"</string>
<string name="screen_app_lock_biometric_unlock">"biometrik qulf ochish"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Biometrik bilan qulfni oching"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Biometrikni tasdiqlang"</string>
<string name="screen_app_lock_forgot_pin">"PIN kodni unutdingizmi?"</string>
<string name="screen_app_lock_settings_change_pin">"PIN kodni o\'zgartirish"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Biometrik qulfni ochishga ruxsat bering"</string>

View file

@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToBugReport()
fun onDone()
}
fun createNode(

View file

@ -167,6 +167,10 @@ class LoginFlowNode(
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
}
override fun onDone() {
callback.onDone()
}
}
val params = inputs<Params>()
val inputs = OnBoardingNode.Params(

View file

@ -42,6 +42,7 @@ class OnBoardingNode(
fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String)
fun onDone()
}
data class Params(
@ -71,7 +72,7 @@ class OnBoardingNode(
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
onBackClick = ::navigateUp,
onBackClick = callback::onDone,
)
}
}

View file

@ -13,9 +13,17 @@
<string name="screen_change_account_provider_other">"Boshqa"</string>
<string name="screen_change_account_provider_subtitle">"Shaxsiy serveringiz yoki ishchi hisob qaydnomangiz kabi boshqa hisob provayderidan foydalaning."</string>
<string name="screen_change_account_provider_title">"Hisob provayderini o\'zgartiring"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s da Element Pro ilovasi talab qilinadi. Iltimos, dokondan yuklab oling."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro talab qilinadi"</string>
<string name="screen_change_server_error_invalid_homeserver">"Bu uy serveriga kira olmadik. Iltimos, uy serverining URL manzilini to\'ri kiritganingizni tekshiring. Agar URL toʻgʻri boʻlsa, qoʻshimcha yordam olish uchun uy serveri administratoriga murojaat qiling."</string>
<string name="screen_change_server_error_invalid_well_known">".well-known faylidagi muammo tufayli server mavjud emas: %1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Tanlangan hisob provayderi siljitish sinxronizatsiyasini qollab-quvvatlamaydi. %1$s ishlatish uchun serverni yangilash zarur."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s uchun %2$s bilan ulanishga ruxsat berilmagan."</string>
<string name="screen_change_server_error_unauthorized_homeserver_content">"Bu ilova quyidagilarga ruxsat berish uchun sozlangan: %1$s ."</string>
<string name="screen_change_server_error_unauthorized_homeserver_title">"Hisob provayderi %1$s ga ruxsat berilmagan."</string>
<string name="screen_change_server_form_header">"Uy serverining URL manzili"</string>
<string name="screen_change_server_form_notice">"Domen manzilini kiriting."</string>
<string name="screen_change_server_subtitle">"Serveringizning manzili nima?"</string>
<string name="screen_change_server_title">"Serveringizni tanlang"</string>
<string name="screen_create_account_title">"Hisob yaratish"</string>
@ -28,6 +36,7 @@
<string name="screen_login_subtitle">"Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."</string>
<string name="screen_login_title">"Qaytib kelganingizdan xursandmiz!"</string>
<string name="screen_login_title_with_homeserver">"Kirish%1$s"</string>
<string name="screen_onboarding_app_version">"%1$s versiya"</string>
<string name="screen_onboarding_sign_in_manually">"Qo\'lda tizimga kiring"</string>
<string name="screen_onboarding_sign_in_to">"Kirish%1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"QR kod bilan tizimga kiring"</string>
@ -83,5 +92,6 @@ Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."</string>
<string name="screen_server_confirmation_message_register">"Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."</string>
<string name="screen_server_confirmation_title_login">"Siz tizimga kirmoqchisiz%1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Hisob provayderini tanlang"</string>
<string name="screen_server_confirmation_title_register">"Hisob yaratmoqchisiz%1$s"</string>
</resources>

View file

@ -43,6 +43,7 @@ class DefaultLoginEntryPointTest {
}
val callback = object : LoginEntryPoint.Callback {
override fun navigateToBugReport() = lambdaError()
override fun onDone() = lambdaError()
}
val params = LoginEntryPoint.Params(
accountProvider = "ac",

View file

@ -6,6 +6,7 @@
<string name="screen_signout_in_progress_dialog_content">"خارج شدن…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"دارید از واپسین نشستتان خارج می‌شوید. اگر اکنون خارج شوید پیام‌های رمزنگاشته‌تان را از دست خواهید داد."</string>
<string name="screen_signout_key_backup_disabled_title">"پشتیبان را خاموش کرده‌اید"</string>
<string name="screen_signout_key_backup_offline_subtitle">"در هنگامی که آفلاین شدید، کلیدهای شما هنوز در حال پشتیبان‌گیری بودند. دوباره متصل شوید ، تا قبل از خروج از کلیدهایتان نسخه پشتیبان‌ گرفته شود."</string>
<string name="screen_signout_key_backup_offline_title">"کلیدهایتان هنوز در حال پشتیبان گیریند"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"لطفاً پیش از خروج منتظر پایانش شوید."</string>
<string name="screen_signout_key_backup_ongoing_title">"کلیدهایتان هنوز در حال پشتیبان گیریند"</string>

View file

@ -12,17 +12,17 @@ import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
sealed interface VoiceMessageComposerEvents {
sealed interface VoiceMessageComposerEvent {
data class RecorderEvent(
val recorderEvent: VoiceMessageRecorderEvent
) : VoiceMessageComposerEvents
) : VoiceMessageComposerEvent
data class PlayerEvent(
val playerEvent: VoiceMessagePlayerEvent,
) : VoiceMessageComposerEvents
data object SendVoiceMessage : VoiceMessageComposerEvents
data object DeleteVoiceMessage : VoiceMessageComposerEvents
data object AcceptPermissionRationale : VoiceMessageComposerEvents
data object DismissPermissionsRationale : VoiceMessageComposerEvents
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvents
data object DismissSendFailureDialog : VoiceMessageComposerEvents
) : VoiceMessageComposerEvent
data object SendVoiceMessage : VoiceMessageComposerEvent
data object DeleteVoiceMessage : VoiceMessageComposerEvent
data object AcceptPermissionRationale : VoiceMessageComposerEvent
data object DismissPermissionsRationale : VoiceMessageComposerEvent
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvent
data object DismissSendFailureDialog : VoiceMessageComposerEvent
}

View file

@ -17,5 +17,5 @@ data class VoiceMessageComposerState(
val showPermissionRationaleDialog: Boolean,
val showSendFailureDialog: Boolean,
val keepScreenOn: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit,
val eventSink: (VoiceMessageComposerEvent) -> Unit,
)

View file

@ -68,7 +68,9 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@ -136,6 +138,9 @@ class MessagesNode(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onResume = {
analyticsService.finishLongRunningTransaction(LoadMessagesUi)
},
onDestroy = {
mediaPlayer.close()
}

View file

@ -44,7 +44,7 @@ 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.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
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
@ -120,7 +120,7 @@ fun MessagesView(
knockRequestsBannerView: @Composable () -> Unit,
) {
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
@ -399,17 +399,17 @@ private fun MessagesViewContent(
if (state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog(
onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
},
onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
},
appName = state.appName
)
}
if (state.voiceMessageComposerState.showSendFailureDialog) {
VoiceMessageSendingFailedDialog(
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) },
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog) },
)
}

View file

@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
@ -78,19 +78,19 @@ internal fun MessageComposerView(
}
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press))
voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press))
}
val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
}
val onDeleteVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
}
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event))
voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event))
}
TextComposer(

View file

@ -55,11 +55,17 @@ 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.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -86,6 +92,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@AssistedFactory
@ -108,6 +115,11 @@ class TimelinePresenter(
@Composable
override fun present(): TimelineState {
LaunchedEffect(Unit) {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(DisplayFirstTimelineItems, parent)
}
val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() }
@ -195,6 +207,9 @@ class TimelinePresenter(
focusOnEvent(event.eventId, focusRequestState)
}.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)
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}
is TimelineEvents.ClearFocusRequestState -> {
@ -227,17 +242,27 @@ class TimelinePresenter(
.onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems
analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems)
finishLongRunningTransaction(OpenRoom)
}
}
.launchIn(this)
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())
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
)
transaction?.finish()
items
}
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.flowOn(dispatchers.computation)
.launchIn(this)
}

View file

@ -103,7 +103,7 @@ import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
@ -542,7 +542,7 @@ private fun TimelineItemEventRowContent(
@Composable
private fun MessageSenderInformation(
senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderProfile: ProfileDetails,
senderAvatar: AvatarData,
onClick: () -> Unit,
modifier: Modifier = Modifier
@ -844,7 +844,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
@ -877,7 +877,7 @@ internal fun ThreadSummaryViewPreview() {
type = TextMessageType(body, null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready(
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = true,

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInv
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -63,7 +63,7 @@ class TimelineItemContentFactory(
eventId: EventId?,
isEditable: Boolean,
sender: UserId,
senderProfile: ProfileTimelineDetails,
senderProfile: ProfileDetails,
): TimelineItemEventContent {
val isOutgoing = sessionId == sender
return when (itemContent) {

View file

@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
@ -69,7 +69,7 @@ sealed interface TimelineItem {
val eventId: EventId? = null,
val transactionId: TransactionId? = null,
val senderId: UserId,
val senderProfile: ProfileTimelineDetails,
val senderProfile: ProfileDetails,
val senderAvatar: AvatarData,
val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L,

View file

@ -26,7 +26,7 @@ import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope
@ -164,25 +164,25 @@ class DefaultVoiceMessageComposerPresenter(
}
}
fun handleEvent(event: VoiceMessageComposerEvents) {
fun handleEvent(event: VoiceMessageComposerEvent) {
when (event) {
is VoiceMessageComposerEvents.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvents.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch {
sendVoiceMessage()
}
VoiceMessageComposerEvents.DeleteVoiceMessage -> {
VoiceMessageComposerEvent.DeleteVoiceMessage -> {
player.pause()
localCoroutineScope.deleteRecording()
}
VoiceMessageComposerEvents.DismissPermissionsRationale -> {
VoiceMessageComposerEvent.DismissPermissionsRationale -> {
permissionState.eventSink(PermissionsEvents.CloseDialog)
}
VoiceMessageComposerEvents.AcceptPermissionRationale -> {
VoiceMessageComposerEvent.AcceptPermissionRationale -> {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
}
is VoiceMessageComposerEvents.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvents.DismissSendFailureDialog -> {
is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvent.DismissSendFailureDialog -> {
showSendFailureDialog = false
}
}
@ -192,7 +192,10 @@ class DefaultVoiceMessageComposerPresenter(
voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime,
levels = state.levels.toImmutableList(),
levels = state.levels
// Keep only the last 128 samples for display, else we can have a crash
.takeLast(128)
.toImmutableList(),
)
is VoiceRecorderState.Finished ->
previewState(

View file

@ -42,6 +42,10 @@
<string name="screen_room_timeline_reactions_show_more">"نمایش بیش‌تر"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"نمایش خلاصهٔ واکنش‌ها"</string>
<string name="screen_room_timeline_read_marker_title">"جدید"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dتغییر اتاق"</item>
<item quantity="other">"%1$dتغییر اتاق"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"پرش به اتاق جدید"</string>
<string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string>
<string name="screen_room_timeline_upgraded_room_action">"دیدن پیام‌های قدیمی"</string>

View file

@ -7,6 +7,7 @@
<string name="emoji_picker_category_objects">"Oggetti"</string>
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tocca per modificare la qualità di caricamento del video"</string>
@ -15,6 +16,7 @@
<string name="screen_media_upload_preview_error_failed_sending">"Caricamento del file multimediale fallito, riprova."</string>
<string name="screen_media_upload_preview_error_too_large_message">"La dimensione massima consentita del file è %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Il file è troppo grande per essere caricato"</string>
<string name="screen_media_upload_preview_item_count">"Elemento %1$d di %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Ottimizza la qualità delle immagini"</string>
<string name="screen_media_upload_preview_processing">"Elaborazione…"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>

View file

@ -7,13 +7,18 @@
<string name="emoji_picker_category_objects">"Objetos"</string>
<string name="emoji_picker_category_people">"Sorrisos &amp; Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens &amp; Lugares"</string>
<string name="emoji_picker_category_recent">"Emojis recentes"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam apps mais antigos."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Toque para alterar a qualidade do envio do vídeo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"O arquivo não pôde ser enviado."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar a mídia para o envio. Tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Falha ao enviar mídia. Tente novamente."</string>
<string name="screen_media_upload_preview_error_too_large_message">"O tamanho de arquivo máximo permitido é %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"O arquivo é muito grande para enviar"</string>
<string name="screen_media_upload_preview_item_count">"%1$d de %2$d itens"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Otimizar qualidade da imagem"</string>
<string name="screen_media_upload_preview_processing">"Processando…"</string>
<string name="screen_report_content_block_user">"Bloquear usuário"</string>
<string name="screen_report_content_block_user_hint">"Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário"</string>
<string name="screen_report_content_explanation">"Essa mensagem será reportada ao administrador do seu servidor-casa. Eles não conseguirão ler nenhuma mensagem criptografada."</string>

View file

@ -8,8 +8,15 @@
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</string>
<string name="screen_media_upload_preview_caption_warning">"Taglavhalar eski ilovalardan foydalanuvchilarga korinmasligi mumkin."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Video yuklash sifatini oʻzgartirish uchun bosing"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faylni yuklab boʻlmadi."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Mediani yuklab bolmadi, qayta urinib koring."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Media yuklanmadi, qayta urinib koring."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Ruxsat etilgan maksimal fayl hajmi %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Fayl yuklash uchun juda katta"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Tasvir sifatini optimallashtirish"</string>
<string name="screen_media_upload_preview_processing">"Qayta ishlanmoqda…"</string>
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>
@ -33,16 +40,31 @@
<string name="screen_room_timeline_add_reaction">"Emoji qo\'shmoq"</string>
<string name="screen_room_timeline_beginning_of_room">"Bu %1$sni boshlanishi"</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Bu suhbatning boshlanishi."</string>
<string name="screen_room_timeline_legacy_call">"Chaqiruv qabul qilinmaydi. Chaqiruvchidan yangi Element X ilovasidan foydalanishi mumkinligini sorang."</string>
<string name="screen_room_timeline_less_reactions">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_message_copied">"Xabar nusxalandi"</string>
<string name="screen_room_timeline_no_permission_to_post">"Sizda bu xonaga post yozishga ruxsat yoq"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d ta azo %2$s bilan munosabat bildirdi"</item>
<item quantity="other">"%1$d ta azo %2$s bilan munosabat bildirdi"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Siz va %1$d ta azo %2$s bilan munosabat bildirdi"</item>
<item quantity="other">"Siz va %1$d ta azo %2$s bilan munosabat bildirdi"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"%1$s bilan munosabat bildirdingiz"</string>
<string name="screen_room_timeline_reactions_show_less">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_reactions_show_more">"Ko\'proq ko\'rsatish"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reaksiyalar xulosasini chiqarish"</string>
<string name="screen_room_timeline_read_marker_title">"Yangi"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dxonani almashtirish"</item>
<item quantity="other">"%1$dxona o\'zgarishi"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"Yangi xonaga otish"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Bu room almashtirildi va endi faol emas"</string>
<string name="screen_room_timeline_upgraded_room_action">"Eski xabarlarni korish"</string>
<string name="screen_room_timeline_upgraded_room_message">"Bu xona boshqa xonaning davomi"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s va %3$d boshqalar"</item>
<item quantity="other">"%1$s, %2$s va %3$d boshqalar"</item>

View file

@ -60,6 +60,7 @@ import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -1054,6 +1055,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
analyticsService = FakeAnalyticsService(),
)
}
}

View file

@ -12,13 +12,10 @@ package io.element.android.features.messages.impl.voicemessages.composer
import android.Manifest
import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
@ -42,10 +39,12 @@ import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@ -57,7 +56,7 @@ import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest {
class DefaultVoiceMessageComposerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@ -91,9 +90,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
voiceRecorder.assertCalls(started = 0)
@ -105,10 +102,8 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - recording state`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
@ -118,20 +113,42 @@ class VoiceMessageComposerPresenterTest {
}
}
@Test
fun `present - recording state - number of levels is limited`() = runTest {
val numberOfLevels = 200
val levels = List(numberOfLevels) { it / numberOfLevels.toFloat() }
val voiceRecorder = FakeVoiceRecorder(
levels = levels,
recordingDuration = RECORDING_DURATION,
)
val presenter = createDefaultVoiceMessageComposerPresenter(
voiceRecorder = voiceRecorder,
)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
skipItems(numberOfLevels / 2 - 1)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java)
val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording
// The number of levels should be limited to 128 items
assertThat(recordingState.levels.size).isEqualTo(128)
assertThat(recordingState.levels).isEqualTo(levels.takeLast(128))
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - recording keeps screen on`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitItem().apply {
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
assertThat(keepScreenOn).isFalse()
}
awaitItem().apply {
assertThat(keepScreenOn).isTrue()
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
}
val finalState = awaitItem().apply {
@ -145,11 +162,9 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - abort recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
@ -160,11 +175,9 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - finish recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
@ -177,12 +190,10 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - play recording before it is ready`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem().apply {
this.eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
this.eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
}
// Nothing should happen
@ -196,12 +207,10 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - play recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
}
@ -214,13 +223,11 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - pause recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
}
@ -233,18 +240,16 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - seek recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
@ -260,12 +265,10 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - delete recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -278,13 +281,11 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - delete while playing`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPausedState())
}
@ -300,12 +301,10 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -319,21 +318,19 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - sending is tracked`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Send a normal voice message
messageComposerContext.composerMode = MessageComposerMode.Normal
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
skipItems(1) // Sending state
advanceUntilIdle()
// Now reply with a voice message
messageComposerContext.composerMode = aReplyMode()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
val finalState = awaitItem() // Sending state
assertThat(analyticsService.capturedEvents).containsExactly(
@ -348,13 +345,11 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send while playing`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
skipItems(1) // Duplicate sending state
@ -370,14 +365,12 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send recording before previous completed, waits`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().run {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
}
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
@ -395,20 +388,18 @@ class VoiceMessageComposerPresenterTest {
// Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception()))
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState())
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
}
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(0)
assertThat(analyticsService.trackedErrors).isEmpty()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
testPauseAndDestroy(finalState)
@ -419,15 +410,13 @@ class VoiceMessageComposerPresenterTest {
fun `present - send failures can be retried`() = runTest {
// Let sending fail due to media preprocessing error
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
mediaPreProcessor.givenResult(Result.failure(Exception()))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed()
@ -435,7 +424,7 @@ class VoiceMessageComposerPresenterTest {
sendVoiceMessageResult.assertions().isNeverCalled()
mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isCalledOnce()
@ -448,14 +437,12 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send failures are displayed as an error dialog`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception()))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
@ -467,7 +454,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState())
assertThat(showSendFailureDialog).isTrue()
eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog)
eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog)
}
val finalState = awaitItem().apply {
@ -483,12 +470,10 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
// Send the message before recording anything
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
initialState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendVoiceMessageResult.assertions().isNeverCalled()
@ -504,11 +489,9 @@ class VoiceMessageComposerPresenterTest {
val exception = SecurityException("")
voiceRecorder.givenThrowsSecurityException(exception)
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).containsExactly(
@ -528,19 +511,17 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
voiceRecorder.assertCalls(stopped = 1)
permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(stopped = 1, started = 1)
@ -557,16 +538,14 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
// See the dialog and accept it
awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
it.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
}
// Dialog is hidden, user accepts permissions
@ -574,7 +553,7 @@ class VoiceMessageComposerPresenterTest {
permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1)
@ -591,22 +570,20 @@ class VoiceMessageComposerPresenterTest {
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
// See the dialog and accept it
awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
it.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
}
// Dialog is hidden, user tries to record again
awaitItem().also {
assertThat(it.showPermissionRationaleDialog).isFalse()
it.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
it.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}
// Dialog is shown once again
@ -624,7 +601,7 @@ class VoiceMessageComposerPresenterTest {
mostRecentState: VoiceMessageComposerState,
) {
mostRecentState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
)
val onPauseState = when (val state = mostRecentState.voiceMessageState) {
@ -645,7 +622,7 @@ class VoiceMessageComposerPresenterTest {
}
onPauseState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
)
when (val state = onPauseState.voiceMessageState) {
@ -662,6 +639,7 @@ class VoiceMessageComposerPresenterTest {
private fun TestScope.createDefaultVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder,
): DefaultVoiceMessageComposerPresenter {
return DefaultVoiceMessageComposerPresenter(
sessionCoroutineScope = backgroundScope,

View file

@ -15,7 +15,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
@ -86,7 +86,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
reactions = persistentListOf(),
receipts = persistentListOf(),
sender = A_USER_ID,
senderProfile = ProfileTimelineDetails.Unavailable,
senderProfile = ProfileDetails.Unavailable,
timestamp = 9442,
content = RedactedContent,
origin = null,

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"Jami ovozlarning %1$d foizi"</item>
<item quantity="other">"Jami ovozlarning %1$d foizi"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Oldingi tanlov olib tashlanadi"</string>
<string name="a11y_polls_winning_answer">"Bu g\'alaba qozongan javob"</string>
</resources>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Ovozlarni yashirish"</string>
<string name="screen_create_poll_answer_hint">"Variant%1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Oʻzgarishlar saqlanmadi. Haqiqatan ham orqaga qaytmoqchimisiz?"</string>
<string name="screen_create_poll_delete_option_a11y">"%1$s variantini ochirish"</string>
<string name="screen_create_poll_question_desc">"Savol yoki mavzu"</string>
<string name="screen_create_poll_question_hint">"So\'rovnoma nima haqida?"</string>
<string name="screen_create_poll_title">"Sorovnoma yaratish"</string>

View file

@ -186,11 +186,20 @@ class PreferencesFlowNode(
override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory)
}
override fun onDone() {
backstack.pop()
}
}
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
}
NavTarget.Labs -> {
createNode<LabsNode>(buildContext)
val callback = object : LabsNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<LabsNode>(buildContext, listOf(callback))
}
NavTarget.About -> {
val callback = object : AboutNode.Callback {
@ -267,7 +276,12 @@ class PreferencesFlowNode(
}
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
val callback = object : EditUserProfileNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<EditUserProfileNode>(buildContext, listOf(inputs, callback))
}
NavTarget.LockScreenSettings -> {
lockScreenEntryPoint.createNode(

View file

@ -31,6 +31,7 @@ class DeveloperSettingsNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToPushHistory()
fun onDone()
}
private val callback: Callback = callback()
@ -49,7 +50,7 @@ class DeveloperSettingsNode(
modifier = modifier,
onOpenShowkase = ::openShowkase,
onPushHistoryClick = callback::navigateToPushHistory,
onBackClick = ::navigateUp
onBackClick = callback::onDone,
)
}
}

View file

@ -16,6 +16,7 @@ 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)
@ -25,9 +26,18 @@ class LabsNode(
@Assisted plugins: List<Plugin>,
private val presenter: LabsPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
}
val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LabsView(state = state, onBack = ::navigateUp)
LabsView(
state = state,
onBack = callback::onDone,
)
}
}

View file

@ -13,6 +13,7 @@ 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 CancelSaveChanges : EditUserProfileEvents
data object CloseDialog : EditUserProfileEvents
}

View file

@ -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.preferences.impl.user.editprofile
interface EditUserProfileNavigator {
fun close()
}

View file

@ -17,6 +17,7 @@ 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
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -27,22 +28,32 @@ class EditUserProfileNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
EditUserProfileNavigator {
data class Inputs(
val matrixUser: MatrixUser
) : NodeInputs
interface Callback : Plugin {
fun onDone()
}
val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser)
val callback: Callback = callback()
val presenter = presenterFactory.create(
matrixUser = matrixUser,
navigator = this,
)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditUserProfileView(
state = state,
onBackClick = ::navigateUp,
onEditProfileSuccess = ::navigateUp,
onEditProfileSuccess = ::close,
modifier = modifier
)
}
override fun close() = callback.onDone()
}

View file

@ -45,6 +45,7 @@ import timber.log.Timber
@AssistedInject
class EditUserProfilePresenter(
@Assisted private val matrixUser: MatrixUser,
@Assisted private val navigator: EditUserProfileNavigator,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
@ -57,7 +58,10 @@ class EditUserProfilePresenter(
@AssistedFactory
interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
fun create(
matrixUser: MatrixUser,
navigator: EditUserProfileNavigator,
): EditUserProfilePresenter
}
@Composable
@ -101,6 +105,13 @@ class EditUserProfilePresenter(
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
fun handleEvent(event: EditUserProfileEvents) {
when (event) {
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
@ -124,18 +135,32 @@ class EditUserProfilePresenter(
}
}
}
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized
EditUserProfileEvents.Exit -> {
when (saveAction.value) {
is AsyncAction.Confirming -> {
// Close the dialog right now
saveAction.value = AsyncAction.Uninitialized
navigator.close()
}
AsyncAction.Loading -> Unit
is AsyncAction.Failure,
is AsyncAction.Success -> {
// Should not happen
}
AsyncAction.Uninitialized -> {
if (canSave) {
saveAction.value = AsyncAction.ConfirmingCancellation
} else {
navigator.close()
}
}
}
}
EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
}
}
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
return EditUserProfileState(
userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(),

View file

@ -11,27 +11,36 @@ package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<EditUserProfileState>
get() = sequenceOf(
aEditUserProfileState(),
aEditUserProfileState(userAvatarUrl = "example://uri"),
// Add other states here
aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
)
}
fun aEditUserProfileState(
userId: UserId = UserId("@john.doe:matrix.org"),
displayName: String = "John Doe",
userAvatarUrl: String? = null,
avatarActions: List<AvatarAction> = emptyList(),
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
eventSink: (EditUserProfileEvents) -> Unit = {},
) = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"),
displayName = "John Doe",
userId = userId,
displayName = displayName,
userAvatarUrl = userAvatarUrl,
avatarActions = persistentListOf(),
saveAction = AsyncAction.Uninitialized,
saveButtonEnabled = true,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = {}
avatarActions = avatarActions.toImmutableList(),
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction,
cameraPermissionState = cameraPermissionState,
eventSink = eventSink,
)

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.user.editprofile
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -30,11 +31,13 @@ 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.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.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditUserProfileView(
state: EditUserProfileState,
onBackClick: () -> Unit,
onEditProfileSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -64,12 +66,21 @@ fun EditUserProfileView(
isAvatarActionsSheetVisible.value = true
}
fun onBackClick() {
focusManager.clearFocus()
state.eventSink(EditUserProfileEvents.Exit)
}
BackHandler(
enabled = true,
::onBackClick,
)
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
TopAppBar(
titleStr = stringResource(R.string.screen_edit_profile_title),
navigationIcon = { BackButton(onClick = onBackClick) },
navigationIcon = { BackButton(::onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
@ -132,10 +143,20 @@ fun EditUserProfileView(
progressText = stringResource(R.string.screen_edit_profile_updating_details),
)
},
confirmationDialog = { confirming ->
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) },
onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }
)
}
}
},
onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
)
}
PermissionsView(
@ -148,7 +169,6 @@ fun EditUserProfileView(
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreview {
EditUserProfileView(
onBackClick = {},
onEditProfileSuccess = {},
state = state,
)

View file

@ -10,6 +10,7 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL non valido, assicurati di includere il protocollo (http/https) e l\'indirizzo corretto."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Nascondi gli avatar nelle richieste di invito alle stanze"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Nascondi le anteprime dei media nelle conversazioni"</string>
<string name="screen_advanced_settings_labs">"Labs"</string>
<string name="screen_advanced_settings_media_compression_description">"Carica foto e video più velocemente e riduci l\'utilizzo dei dati"</string>
<string name="screen_advanced_settings_media_compression_title">"Ottimizza la qualità dei contenuti multimediali"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderazione e Sicurezza"</string>
@ -43,6 +44,11 @@
<string name="screen_edit_profile_error_title">"Impossibile aggiornare il profilo"</string>
<string name="screen_edit_profile_title">"Modifica profilo"</string>
<string name="screen_edit_profile_updating_details">"Aggiornamento del profilo…"</string>
<string name="screen_labs_enable_threads">"Abilita le risposte alle discussioni"</string>
<string name="screen_labs_enable_threads_description">"L\'app si riavvierà per applicare questa modifica."</string>
<string name="screen_labs_header_description">"Prova le nostre ultime idee in fase di sviluppo. Queste funzionalità non sono definitive; potrebbero essere instabili e soggette a modifiche."</string>
<string name="screen_labs_header_title">"Hai voglia di sperimentare?"</string>
<string name="screen_labs_title">"Labs"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Impostazioni aggiuntive"</string>
<string name="screen_notification_settings_calls_label">"Chiamate audio e video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Mancata corrispondenza di configurazione"</string>

View file

@ -10,9 +10,17 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL inválida, por favor verifique se o protocolo (http/https) está incluso e o endereço correto."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Ocultar avatares em solicitações de convite para salas"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Ocultar pré-visualizações de mídia na linha do tempo"</string>
<string name="screen_advanced_settings_labs">"Experimentos"</string>
<string name="screen_advanced_settings_media_compression_description">"Envie fotos e vídeos com mais rapidez e reduza o uso de dados"</string>
<string name="screen_advanced_settings_media_compression_title">"Otimizar a qualidade da mídia"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderação e segurança"</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_description">"Otimizar automaticamente as imagens para envios mais rápidos e arquivos com tamanhos menores."</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_title">"Otimizar qualidade de envio de imagens"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_description">"%1$s. Toque aqui para alterar."</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_high">"Alta (1080p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_low">"Baixa (480p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_standard">"Normal (720p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"Qualidade de envio de vídeos"</string>
<string name="screen_advanced_settings_push_provider_android">"Provedor de notificações push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Desative o editor de rich text para digitar Markdown manualmente."</string>
<string name="screen_advanced_settings_send_read_receipts">"Confirmações de leitura"</string>
@ -36,6 +44,11 @@
<string name="screen_edit_profile_error_title">"Não foi possível atualizar o perfil"</string>
<string name="screen_edit_profile_title">"Editar perfil"</string>
<string name="screen_edit_profile_updating_details">"Atualizando o perfil…"</string>
<string name="screen_labs_enable_threads">"Ativar respostas de tópicos"</string>
<string name="screen_labs_enable_threads_description">"O app será reiniciado para aplicar esta mudança."</string>
<string name="screen_labs_header_description">"Teste as nossas mais novas ideias em desenvolvimento. Esses recursos não estão finalizados; podem estar instáveis, e podem mudar."</string>
<string name="screen_labs_header_title">"Se sentindo experimental?"</string>
<string name="screen_labs_title">"Experimentos"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Configurações adicionais"</string>
<string name="screen_notification_settings_calls_label">"Chamadas de áudio e vídeo"</string>
<string name="screen_notification_settings_configuration_mismatch">"Não correspondência de configuração"</string>

View file

@ -8,14 +8,29 @@
<string name="screen_advanced_settings_element_call_base_url">"Maxsus element qongiroqlar bazasi URL manzili"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Element qo\'ng\'irog\'iga maxsus asosiy url or\'natish"</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL notogri, iltimos, protokol (http/https) va togri manzilni kiritganingizga ishonch hosil qiling."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Xonaga taklif sorovlarida avatarlarni berkitish"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Vaqt jadvalida mediaga razm solishlarni berkitish"</string>
<string name="screen_advanced_settings_media_compression_description">"Rasm va videolarni tezroq yuklang va trafik sarfini kamaytiring"</string>
<string name="screen_advanced_settings_media_compression_title">"Media sifatini yaxshilash"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderatsiya va xavfsizlik"</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_description">"Tezroq yuklash va kichikroq fayl hajmi uchun rasmlarni avtomatik optimallashtirish."</string>
<string name="screen_advanced_settings_optimise_image_upload_quality_title">"Rasm yuklash sifatini optimallashtirish"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_description">"%1$s. Oʻzgartirish uchun bu yerga bosing."</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_high">"Yuqori (1080p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_low">"Past (480p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_standard">"Standart (720p)"</string>
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"Video yuklash sifati"</string>
<string name="screen_advanced_settings_push_provider_android">"Push bildirishnoma provayderi"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Boy matn muharriri o\'chiring Markdown bilan qo\'lda yozish uchun"</string>
<string name="screen_advanced_settings_send_read_receipts">"Kvitansiyalarni oʻqish"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Agar oʻchirib qoyilsa, sizning oʻqilganlik bildirishnomangiz hech kimga yuborilmaydi. Siz boshqa foydalanuvchilardan oʻqilganlik bildirishnomalarini olishda davom etasiz."</string>
<string name="screen_advanced_settings_share_presence">"Mavjudligini ulashish"</string>
<string name="screen_advanced_settings_share_presence_description">"Agar oʻchirib qoʻyilsa, siz oʻqilganlik haqidagi bildirishnomalarni yoki yozayotganingiz haqidagi xabarlarni yubora olmaysiz va qabul qila olmaysiz."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Doim berkitilsin"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Har doim korsatish"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"Shaxsiy xonalarda"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Yashirin media har doim unga bosish orqali korsatilishi mumkin"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Vaqt jadvalida media korsatish"</string>
<string name="screen_advanced_settings_view_source_description">"Xabar manbasini vaqt jadvalida korish imkoniyatini yoqing."</string>
<string name="screen_blocked_users_empty">"Sizda bloklangan foydalanuvchi yoq"</string>
<string name="screen_blocked_users_unblock_alert_action">"Blokdan chiqarish"</string>
@ -55,6 +70,7 @@ Davom ettirsangiz, baʼzi sozlamalaringiz oʻzgarishi mumkin."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"tizim sozlamalari"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Tizim bildirishnomalari o\'chirilgan"</string>
<string name="screen_notification_settings_title">"Bildirishnomalar"</string>
<string name="troubleshoot_notifications_entry_point_push_history_title">"Bildirishnoma tarixi"</string>
<string name="troubleshoot_notifications_entry_point_section">"Muammolarni bartaraf etish"</string>
<string name="troubleshoot_notifications_entry_point_title">"Bildirishnomalar bilan bogliq muammolarni bartaraf etish"</string>
</resources>

View file

@ -44,6 +44,10 @@
<string name="screen_edit_profile_error_title">"无法更新个人资料"</string>
<string name="screen_edit_profile_title">"编辑个人资料"</string>
<string name="screen_edit_profile_updating_details">"更新个人资料……"</string>
<string name="screen_labs_enable_threads">"启用主题回复"</string>
<string name="screen_labs_enable_threads_description">"应用将重启以应用此更改。"</string>
<string name="screen_labs_header_description">"尝试我们最新的开发理念。这些功能尚未最终确定,可能不稳定,也可能会发生变化。"</string>
<string name="screen_labs_header_title">"想尝试新功能?"</string>
<string name="screen_labs_title">"实验室"</string>
<string name="screen_notification_settings_additional_settings_section_title">"更多设置"</string>
<string name="screen_notification_settings_calls_label">"音视频通话"</string>

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