Merge branch 'release/25.12.0'

This commit is contained in:
Jorge Martín 2025-12-03 10:41:56 +01:00
commit 98ad9ed229
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
@ -218,10 +222,13 @@ private fun NameAndTimestampRow(
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
// Name
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
@ -229,6 +236,29 @@ private fun NameAndTimestampRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Picto
when (latestEvent) {
is LatestEvent.Sending -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Time(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
is LatestEvent.Error -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
)
}
else -> Unit
}
}
// Timestamp
Text(
text = timestamp ?: "",
@ -274,12 +304,30 @@ 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()
}
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
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,
@ -287,8 +335,10 @@ private fun MessagePreviewAndIndicatorRow(
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
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" +
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.",
"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,16 +135,30 @@ 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()
}
}
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
}
EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
}
}
return EditUserProfileState(

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