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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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 # 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' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
with: with:

View file

@ -11,7 +11,7 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/setup-java@v5 - uses: actions/setup-java@v5
name: Use JDK 21 name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' 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) }} group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@ -62,7 +62,7 @@ jobs:
group: ${{ format('maestro-{0}', github.ref) }} group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true cancel-in-progress: true
steps: 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' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # 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 runs-on: ubuntu-latest
if: ${{ github.repository == 'element-hq/element-x-android' }} if: ${{ github.repository == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:

View file

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

View file

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

View file

@ -18,7 +18,7 @@ jobs:
group: ${{ format('build-release-main-gplay-{0}', github.sha) }} group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
@ -52,7 +52,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }} group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 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' }} 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) }} group: ${{ format('build-release-main-fdroid-{0}', github.sha) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:

View file

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

View file

@ -11,7 +11,7 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py ./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py ./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings - 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: with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy 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' }} 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. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script - name: Run SAS String script
run: ./tools/sas/import_sas_strings.py run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings - 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: with:
commit-message: Sync SAS Strings commit-message: Sync SAS Strings
title: 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 Changes in Element X v25.11.2
============================= =============================

View file

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

View file

@ -41,8 +41,6 @@
static int windowAttachCount(android.view.View); 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 LogSessionId class and related classes (https://github.com/androidx/media/issues/2535)
-keep class android.media.metrics.LogSessionId { *; } -keep class android.media.metrics.LogSessionId { *; }
@ -51,3 +49,24 @@
# Keep Media3 classes that use reflection (https://github.com/androidx/media/issues/2535) # Keep Media3 classes that use reflection (https://github.com/androidx/media/issues/2535)
-keep class androidx.media3.** { *; } -keep class androidx.media3.** { *; }
-dontwarn android.media.metrics.** -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.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
@ -32,10 +33,12 @@ class DefaultIntentProvider(
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
eventId: EventId?, eventId: EventId?,
extras: Bundle?,
): Intent { ): Intent {
return Intent(context, MainActivity::class.java).apply { return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri() 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 com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration 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.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias 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.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.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest 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.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService 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 io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -108,6 +109,7 @@ import java.util.UUID
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -139,6 +141,7 @@ class LoggedInFlowNode(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
@ -202,6 +205,7 @@ class LoggedInFlowNode(
} }
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
@ -238,6 +242,7 @@ class LoggedInFlowNode(
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null) matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop()
} }
) )
setupSendingQueue() setupSendingQueue()
@ -261,7 +266,7 @@ class LoggedInFlowNode(
data class Room( data class Room(
val roomIdOrAlias: RoomIdOrAlias, val roomIdOrAlias: RoomIdOrAlias,
val serverNames: List<String> = emptyList(), val serverNames: List<String> = emptyList(),
val trigger: JoinedRoom.Trigger? = null, val trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
val roomDescription: RoomDescription? = null, val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(), val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(),
val targetId: UUID = UUID.randomUUID(), val targetId: UUID = UUID.randomUUID(),
@ -311,8 +316,13 @@ class LoggedInFlowNode(
} }
NavTarget.Home -> { NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback { val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) backstack.push(
NavTarget.Room(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom)
)
)
} }
override fun navigateToSettings() { override fun navigateToSettings() {
@ -361,7 +371,7 @@ class LoggedInFlowNode(
val target = NavTarget.Room( val target = NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias, roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters, serverNames = data.viaParameters,
trigger = JoinedRoom.Trigger.Timeline, trigger = JoinedRoomAnalyticsEvent.Trigger.Timeline,
initialElement = RoomNavigationTarget.Root(data.eventId), initialElement = RoomNavigationTarget.Root(data.eventId),
) )
if (pushToBackstack) { if (pushToBackstack) {
@ -475,7 +485,7 @@ class LoggedInFlowNode(
NavTarget.Room( NavTarget.Room(
roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(), roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(),
roomDescription = roomDescription, roomDescription = roomDescription,
trigger = JoinedRoom.Trigger.RoomDirectory, trigger = JoinedRoomAnalyticsEvent.Trigger.RoomDirectory,
) )
) )
} }
@ -515,7 +525,7 @@ class LoggedInFlowNode(
suspend fun attachRoom( suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias, roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(), serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger? = null, trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
eventId: EventId? = null, eventId: EventId? = null,
clearBackstack: Boolean, clearBackstack: Boolean,
): RoomFlowNode { ): 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.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -43,6 +44,7 @@ class NotLoggedInFlowNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val loginEntryPoint: LoginEntryPoint, private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>( ) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -57,6 +59,7 @@ class NotLoggedInFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun onDone()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -64,6 +67,7 @@ class NotLoggedInFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn()
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@ -83,6 +87,10 @@ class NotLoggedInFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
callback.navigateToBugReport() callback.navigateToBugReport()
} }
override fun onDone() {
callback.onDone()
}
} }
loginEntryPoint.createNode( loginEntryPoint.createNode(
parentNode = this, 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.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode 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.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -86,6 +90,8 @@ class RootFlowNode(
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.SplashScreen,
@ -95,6 +101,7 @@ class RootFlowNode(
plugins = plugins plugins = plugins
) { ) {
override fun onBuilt() { override fun onBuilt() {
analyticsColdStartWatcher.start()
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt() super.onBuilt()
observeNavState() observeNavState()
@ -243,6 +250,10 @@ class RootFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport) backstack.push(NavTarget.BugReport)
} }
override fun onDone() {
backstack.pop()
}
} }
val params = NotLoggedInFlowNode.Params( val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params, loginParams = navTarget.params,
@ -306,7 +317,13 @@ class RootFlowNode(
suspend fun handleIntent(intent: Intent) { suspend fun handleIntent(intent: Intent) {
val resolvedIntent = intentResolver.resolve(intent) ?: return val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) { 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.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) 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.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.active
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode 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.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.ui.room.LoadingRoomState 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.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@ -56,10 +60,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
import kotlin.jvm.optionals.getOrNull 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) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -70,9 +77,17 @@ class RoomFlowNode(
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val membershipObserver: RoomMembershipObserver, private val membershipObserver: RoomMembershipObserver,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( 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, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -82,7 +97,7 @@ class RoomFlowNode(
val roomIdOrAlias: RoomIdOrAlias, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
val serverNames: List<String>, val serverNames: List<String>,
val trigger: Optional<JoinedRoom.Trigger>, val trigger: Optional<JoinedRoomAnalyticsEvent.Trigger>,
val initialElement: RoomNavigationTarget, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
@ -99,15 +114,23 @@ class RoomFlowNode(
data class JoinRoom( data class JoinRoom(
val roomId: RoomId, val roomId: RoomId,
val serverNames: List<String>, val serverNames: List<String>,
val trigger: im.vector.app.features.analytics.plan.JoinedRoom.Trigger, val trigger: JoinedRoomAnalyticsEvent.Trigger,
) : NavTarget ) : NavTarget
@Parcelize @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() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId() resolveRoomId()
} }
@ -125,7 +148,9 @@ class RoomFlowNode(
} }
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) { 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 // This observes the local membership changes for the room
val membershipUpdateFlow = membershipObserver.updates val membershipUpdateFlow = membershipObserver.updates
@ -141,6 +166,11 @@ class RoomFlowNode(
currentMembershipFlow.onEach { (previousMembership, membership) -> currentMembershipFlow.onEach { (previousMembership, membership) ->
Timber.d("Room membership: $membership") Timber.d("Room membership: $membership")
if (membership == CurrentUserMembership.JOINED) { 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)) backstack.newRoot(NavTarget.JoinedRoom(roomId))
} else { } else {
val leavingFromCurrentDevice = val leavingFromCurrentDevice =
@ -155,7 +185,7 @@ class RoomFlowNode(
NavTarget.JoinRoom( NavTarget.JoinRoom(
roomId = roomId, roomId = roomId,
serverNames = serverNames, 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 roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs( val inputs = JoinedRoomFlowNode.Inputs(
roomId = navTarget.roomId, roomId = navTarget.roomId,
initialElement = inputs.initialElement initialElement = inputs.initialElement,
joinedRoom = navTarget.joinedRoom,
) )
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }

View file

@ -10,12 +10,15 @@ package io.element.android.appnav.room
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId 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 import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable { sealed interface RoomNavigationTarget : Parcelable {
@Parcelize @Parcelize
data class Root( data class Root(
val eventId: EventId? = null, val eventId: EventId? = null,
@IgnoredOnParcel val joinedRoom: JoinedRoom? = null,
) : RoomNavigationTarget ) : RoomNavigationTarget
@Parcelize @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.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.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.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -63,11 +64,12 @@ class JoinedRoomFlowNode(
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val joinedRoom: JoinedRoom?,
val initialElement: RoomNavigationTarget, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() 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 { sealed interface NavTarget : Parcelable {
@Parcelize @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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.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.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -66,6 +71,7 @@ class JoinedRoomLoadedFlowNode(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
private val analyticsService: AnalyticsService,
roomGraphFactory: RoomGraphFactory, roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -93,6 +99,8 @@ class JoinedRoomLoadedFlowNode(
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(LoadMessagesUi, parent)
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room) activeRoomsHolder.addRoom(inputs.room)
@ -100,6 +108,7 @@ class JoinedRoomLoadedFlowNode(
trackVisitedRoom() trackVisitedRoom()
}, },
onResume = { onResume = {
analyticsService.finishLongRunningTransaction(LoadJoinedRoomFlow)
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
inputs.room.subscribeToSync() 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@ -123,6 +124,7 @@ class JoinedRoomLoadedFlowNodeTest {
roomGraphFactory = FakeRoomGraphFactory(), roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = matrixClient, matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
analyticsService = FakeAnalyticsService(),
) )
@Test @Test

View file

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

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.27") detektPlugins("io.nlopez.compose.rules:detekt:0.4.28")
detektPlugins(project(":tests:detekt-rules")) 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"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"查看您创建或加入的空间"</string> <string name="screen_space_announcement_item1">"查看您创建或加入的空间"</string>
<string name="screen_space_announcement_item2">"接受或拒绝空间邀请"</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> </resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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> <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_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_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_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_name_label">"Nome da sala"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade 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> <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 com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint { interface HomeEntryPoint : FeatureEntryPoint {
fun createNode( fun createNode(
@ -22,7 +23,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
): Node ): Node
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId) fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom() fun navigateToCreateRoom()
fun navigateToSettings() fun navigateToSettings()
fun navigateToSetUpRecovery() fun navigateToSetUpRecovery()

View file

@ -14,6 +14,8 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope 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.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback 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.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.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -71,6 +86,7 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer, private val leaveRoomRenderer: LeaveRoomRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>( ) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -150,9 +166,58 @@ class HomeFlowNode(
return node(buildContext) { modifier -> return node(buildContext) { modifier ->
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current) 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( HomeView(
homeState = state, homeState = state,
onRoomClick = callback::navigateToRoom, onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings, onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom, onStartChatClick = callback::navigateToCreateRoom,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery, onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@ -165,7 +230,7 @@ class HomeFlowNode(
acceptDeclineInviteView = { acceptDeclineInviteView = {
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState, state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = callback::navigateToRoom, onAcceptInviteSuccess = ::navigateToRoom,
onDeclineInviteSuccess = { }, onDeclineInviteSuccess = { },
modifier = Modifier 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R 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.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
@ -120,6 +121,7 @@ internal fun RoomSummaryRow(
) { ) {
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
latestEvent = room.latestEvent,
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
@ -136,6 +138,7 @@ internal fun RoomSummaryRow(
) { ) {
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
latestEvent = room.latestEvent,
timestamp = null, timestamp = null,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
@ -211,6 +214,7 @@ private fun RoomSummaryScaffoldRow(
@Composable @Composable
private fun NameAndTimestampRow( private fun NameAndTimestampRow(
name: String?, name: String?,
latestEvent: LatestEvent,
timestamp: String?, timestamp: String?,
isHighlighted: Boolean, isHighlighted: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -219,16 +223,42 @@ private fun NameAndTimestampRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp) horizontalArrangement = spacedBy(16.dp)
) { ) {
// Name Row(
Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, verticalAlignment = Alignment.CenterVertically,
text = name ?: stringResource(id = CommonStrings.common_no_room_name), ) {
fontStyle = FontStyle.Italic.takeIf { name == null }, // Name
color = ElementTheme.colors.roomListRoomName, Text(
maxLines = 1, style = ElementTheme.typography.fontBodyLgMedium,
overflow = TextOverflow.Ellipsis text = name ?: stringResource(id = CommonStrings.common_no_room_name),
) fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Picto
when (latestEvent) {
is LatestEvent.Sending -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Time(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
is LatestEvent.Error -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
)
}
else -> Unit
}
}
// Timestamp // Timestamp
Text( Text(
text = timestamp ?: "", text = timestamp ?: "",
@ -274,21 +304,41 @@ private fun MessagePreviewAndIndicatorRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp) horizontalArrangement = spacedBy(28.dp)
) { ) {
val messagePreview = if (room.isTombstoned) { if (room.isTombstoned) {
stringResource(R.string.screen_roomlist_tombstoned_room_description) 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 { } else {
room.lastMessage.orEmpty() if (room.latestEvent is LatestEvent.Error) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.common_message_failed_to_send),
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else {
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
} }
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Call and unread // Call and unread
Row( Row(

View file

@ -9,15 +9,17 @@
package io.element.android.features.home.impl.datasource package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject 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.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm 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.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -26,7 +28,7 @@ import kotlinx.collections.immutable.toImmutableList
@Inject @Inject
class RoomListRoomSummaryFactory( class RoomListRoomSummaryFactory(
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter, private val roomLatestEventFormatter: RoomLatestEventFormatter,
) { ) {
fun create(roomSummary: RoomSummary): RoomListRoomSummary { fun create(roomSummary: RoomSummary): RoomListRoomSummary {
val roomInfo = roomSummary.info val roomInfo = roomSummary.info
@ -40,13 +42,11 @@ class RoomListRoomSummaryFactory(
numberOfUnreadNotifications = roomInfo.numUnreadNotifications, numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
isMarkedUnread = roomInfo.isMarkedUnread, isMarkedUnread = roomInfo.isMarkedUnread,
timestamp = dateFormatter.format( timestamp = dateFormatter.format(
timestamp = roomSummary.lastMessageTimestamp, timestamp = roomSummary.latestEventTimestamp,
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
useRelative = true, useRelative = true,
), ),
lastMessage = roomSummary.lastMessage?.let { message -> latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
}.orEmpty(),
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
@ -73,4 +73,28 @@ class RoomListRoomSummaryFactory(
isSpace = roomInfo.isSpace, 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 numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean, val isMarkedUnread: Boolean,
val timestamp: String?, val timestamp: String?,
val lastMessage: CharSequence?, val latestEvent: LatestEvent,
val avatarData: AvatarData, val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,

View file

@ -25,12 +25,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER), aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(), aRoomListRoomSummary(),
aRoomListRoomSummary(name = null), aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null), aRoomListRoomSummary(latestEvent = LatestEvent.None),
aRoomListRoomSummary( aRoomListRoomSummary(
name = "A very long room name that should be truncated", 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(
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", " ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
),
timestamp = "yesterday", timestamp = "yesterday",
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
), ),
@ -44,7 +46,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
listOf( listOf(
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "No activity" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Synced("No activity" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0, numberOfUnreadMentions = 0,
@ -52,7 +54,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New messages" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Synced("New messages" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
numberOfUnreadMentions = 0, numberOfUnreadMentions = 0,
@ -60,7 +62,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New messages, mentions" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Synced("New messages, mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
@ -68,7 +70,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New mentions" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Synced("New mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
@ -127,6 +129,10 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
isTombstoned = true, isTombstoned = true,
) )
), ),
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
)
).flatten() ).flatten()
} }
@ -148,8 +154,8 @@ internal fun aRoomListRoomSummary(
numberOfUnreadMentions: Long = 0, numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0, numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message", latestEvent: LatestEvent = LatestEvent.Synced("Last message"),
timestamp: String? = lastMessage?.let { "88:88" }, timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null, notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
@ -171,7 +177,7 @@ internal fun aRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications, numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
timestamp = timestamp, timestamp = timestamp,
lastMessage = lastMessage, latestEvent = latestEvent,
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, 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.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService 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 io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@ -86,6 +87,7 @@ class RoomListPresenter(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@ -236,6 +238,8 @@ class RoomListPresenter(
) )
showSkeleton -> RoomListContentState.Skeleton(count = 16) showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> { else -> {
coldStartWatcher.onRoomListVisible()
RoomListContentState.Rooms( RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,

View file

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

View file

@ -6,6 +6,7 @@
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان."</string> <string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان."</string>
<string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string> <string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string>
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</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_primary_button_title">"ورود کلید بازیابیتان"</string>
<string name="confirm_recovery_key_banner_title">"ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده"</string> <string name="confirm_recovery_key_banner_title">"ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده"</string>
<string name="full_screen_intent_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_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_submit_android">"Disabilita l\'ottimizzazione"</string>
<string name="banner_battery_optimization_title_android">"Le notifiche non arrivano?"</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_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_submit">"Configura il recupero"</string>
<string name="banner_set_up_recovery_title">"Configura il ripristino"</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_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_submit_android">"Desativar otimização"</string>
<string name="banner_battery_optimization_title_android">"As notificações não chegam?"</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_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_submit">"Configurar a recuperação"</string>
<string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</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">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Você não tem nenhum convite pendente."</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">"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_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_mixed_empty_state_title">"Você não tem conversas para esta seleção"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</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_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="full_screen_intent_banner_title">"Qoʻngʻiroq tajribangizni yaxshilang"</string>
<string name="screen_home_tab_chats">"Suhbatlar"</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_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_chat_title">"Taklifni rad etish"</string>
<string name="screen_invites_decline_direct_chat_message">"Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"</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_message">"Bu bir martalik jarayon, kutganingiz uchun rahmat."</string>
<string name="screen_migration_title">"Hisobingiz sozlanmoqda."</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_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_message">"Kimgadir xabar yuborishdan boshlang."</string>
<string name="screen_roomlist_empty_title">"Hozircha chatlar yoq."</string> <string name="screen_roomlist_empty_title">"Hozircha chatlar yoq."</string>
<string name="screen_roomlist_filter_favourites">"Sevimlilar"</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">"Takliflar"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sizda hech qanday kutilayotgan takliflar yoʻq."</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">"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_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_mixed_empty_state_title">"Sizda bu tanlov uchun chatlar yoq"</string>
<string name="screen_roomlist_filter_people">"Odamlar"</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_main_space_title">"Suhbatlar"</string>
<string name="screen_roomlist_mark_as_read">"Oʻqilgan deb belgilash"</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_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_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> <string name="session_verification_banner_title">"Siz ekanligingizni tasdiqlang"</string>
</resources> </resources>

View file

@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"请关闭本应用的电池优化设置,确保不错过任何消息通知。"</string> <string name="banner_battery_optimization_content_android">"请关闭本应用的电池优化设置,确保不错过任何消息通知。"</string>
<string name="banner_battery_optimization_submit_android">"禁用优化"</string> <string name="banner_battery_optimization_submit_android">"禁用优化"</string>
<string name="banner_battery_optimization_title_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_content">"生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"</string>
<string name="banner_set_up_recovery_submit">"设置恢复"</string> <string name="banner_set_up_recovery_submit">"设置恢复"</string>
<string name="banner_set_up_recovery_title">"设置恢复"</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 com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId 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.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DefaultHomeEntryPointTest { class DefaultHomeEntryPointTest {
@Test @Test
fun `test node builder`() { fun `test node builder`() = runTest {
val entryPoint = DefaultHomeEntryPoint() val entryPoint = DefaultHomeEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
HomeFlowNode( HomeFlowNode(
@ -39,10 +41,11 @@ class DefaultHomeEntryPointTest {
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
) )
} }
val callback = object : HomeEntryPoint.Callback { 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 navigateToCreateRoom() = lambdaError()
override fun navigateToSettings() = lambdaError() override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = 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.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter 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( fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" }, dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" } roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
) = RoomListRoomSummaryFactory( ) = RoomListRoomSummaryFactory(
dateFormatter = dateFormatter, dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter roomLatestEventFormatter = roomLatestEventFormatter,
) )

View file

@ -96,7 +96,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications, numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
timestamp = timestamp, timestamp = timestamp,
lastMessage = "", latestEvent = LatestEvent.Synced(""),
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType, displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode, 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.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter 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.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.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.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService 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.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -638,7 +639,7 @@ class RoomListPresenterTest {
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
dateFormatter: DateFormatter = FakeDateFormatter(), dateFormatter: DateFormatter = FakeDateFormatter(),
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() }, filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
@ -655,7 +656,7 @@ class RoomListPresenterTest {
roomListService = client.roomListService, roomListService = client.roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory( roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter, dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter, roomLatestEventFormatter = roomLatestEventFormatter,
), ),
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService, notificationSettingsService = client.notificationSettingsService,
@ -673,5 +674,6 @@ class RoomListPresenterTest {
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
) )
} }

View file

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

View file

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

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_decline_direct_chat_title">"Chatni rad etish"</string>
<string name="screen_invites_empty_list">"Takliflar yo\'q"</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_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> <string name="screen_join_room_decline_and_block_button_title">"Rad etish va bloklash"</string>
</resources> </resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_by_message">"Sei stato bannato da %1$s ."</string>
<string name="screen_join_room_ban_message">"Sei stato bandito da questa stanza"</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_ban_reason">"Motivo: %1$s"</string>
<string name="screen_join_room_cancel_knock_action">"Cancella richiesta"</string> <string name="screen_join_room_cancel_knock_action">"Cancella richiesta"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sì, annulla"</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_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_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_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_message">"Partecipazione non riuscita"</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_fail_reason">"Devi essere invitato per partecipare o potrebbero esserci delle restrizioni di accesso."</string>
<string name="screen_join_room_forget_action">"Dimentica questa stanza"</string> <string name="screen_join_room_forget_action">"Dimentica"</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_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_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_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> <string name="screen_join_room_knock_action">"Bussa per partecipare"</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_by_message">"Você foi banido por %1$s."</string>
<string name="screen_join_room_ban_message">"Você foi banido desta sala"</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_ban_reason">"Motivo: %1$s."</string>
<string name="screen_join_room_cancel_knock_action">"Cancelar pedido"</string> <string name="screen_join_room_cancel_knock_action">"Cancelar pedido"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sim, cancelar"</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_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_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_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_message">"Falha ao entrar"</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_fail_reason">"Você precisa ser convidado ou pode haver restrições ao acesso."</string>
<string name="screen_join_room_forget_action">"Esquecer esta sala"</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 nesta sala"</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_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_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> <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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_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_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_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_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_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_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_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> <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_by_message">"您已被禁止访问%1$s。"</string>
<string name="screen_join_room_ban_message">"你已被此房间封禁"</string> <string name="screen_join_room_ban_message">"你已被禁止访问"</string>
<string name="screen_join_room_ban_reason">"理由:%1$s。"</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_action">"取消请求"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"是的,取消"</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_message">"您确定要拒绝加入此房间的邀请吗?这也将阻止%1$s 与您联系或邀请您加入房间。"</string>
<string name="screen_join_room_decline_and_block_alert_title">"拒绝邀请并屏蔽"</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_decline_and_block_button_title">"拒绝并屏蔽"</string>
<string name="screen_join_room_fail_message">"加入房间失败"</string> <string name="screen_join_room_fail_message">"加入失败"</string>
<string name="screen_join_room_fail_reason">"要么此房间仅限受邀者,要么可能在空间层级有加入限制。"</string> <string name="screen_join_room_fail_reason">"您需要被邀请加入,否则可能会受到访问限制。"</string>
<string name="screen_join_room_forget_action">"忘记这个房间"</string> <string name="screen_join_room_forget_action">"忘记"</string>
<string name="screen_join_room_invite_required_message">"你需要邀请才能加入这个房间"</string> <string name="screen_join_room_invite_required_message">"您需要邀请才能加入"</string>
<string name="screen_join_room_invited_by">"受邀于"</string> <string name="screen_join_room_invited_by">"受邀于"</string>
<string name="screen_join_room_join_action">"加入"</string> <string name="screen_join_room_join_action">"加入"</string>
<string name="screen_join_room_join_restricted_message">"您可能需要受到邀请或成为某个空间的成员才能加入。"</string> <string name="screen_join_room_join_restricted_message">"您可能需要受到邀请或成为某个空间的成员才能加入。"</string>

View file

@ -1,5 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <string name="screen_room_single_knock_request_view_button_title">"Ko\'rish"</string>
</resources> </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_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_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_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> <string name="leave_room_alert_subtitle">"Xonani tark etmoqchi ekanligingizga ishonchingiz komilmi?"</string>
</resources> </resources>

View file

@ -24,6 +24,14 @@
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"پین‌ها مطابق نیستند"</string> <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_message">"برای ادامه باید دوباره وارد شده و پینی جدید ایجاد کنید"</string>
<string name="screen_app_lock_signout_alert_title">"دارید خارج می‌شوید"</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_biometric_android">"استفاده از زیست‌سنجی"</string>
<string name="screen_app_lock_use_pin_android">"استفاده از پین"</string> <string name="screen_app_lock_use_pin_android">"استفاده از پین"</string>
<string name="screen_signout_in_progress_dialog_content">"خارج شدن…"</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_authentication">"biometrik autentifikatsiya"</string>
<string name="screen_app_lock_biometric_unlock">"biometrik qulf ochish"</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_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_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_change_pin">"PIN kodni o\'zgartirish"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Biometrik qulfni ochishga ruxsat bering"</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 { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun onDone()
} }
fun createNode( fun createNode(

View file

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

View file

@ -42,6 +42,7 @@ class OnBoardingNode(
fun navigateToLoginPassword() fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails) fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String) fun navigateToCreateAccount(url: String)
fun onDone()
} }
data class Params( data class Params(
@ -71,7 +72,7 @@ class OnBoardingNode(
onNeedLoginPassword = callback::navigateToLoginPassword, onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) }, onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount, 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_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_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_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_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_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_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_subtitle">"Serveringizning manzili nima?"</string>
<string name="screen_change_server_title">"Serveringizni tanlang"</string> <string name="screen_change_server_title">"Serveringizni tanlang"</string>
<string name="screen_create_account_title">"Hisob yaratish"</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_subtitle">"Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."</string>
<string name="screen_login_title">"Qaytib kelganingizdan xursandmiz!"</string> <string name="screen_login_title">"Qaytib kelganingizdan xursandmiz!"</string>
<string name="screen_login_title_with_homeserver">"Kirish%1$s"</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_manually">"Qo\'lda tizimga kiring"</string>
<string name="screen_onboarding_sign_in_to">"Kirish%1$s"</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> <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_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_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_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> <string name="screen_server_confirmation_title_register">"Hisob yaratmoqchisiz%1$s"</string>
</resources> </resources>

View file

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

View file

@ -6,6 +6,7 @@
<string name="screen_signout_in_progress_dialog_content">"خارج شدن…"</string> <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_subtitle">"دارید از واپسین نشستتان خارج می‌شوید. اگر اکنون خارج شوید پیام‌های رمزنگاشته‌تان را از دست خواهید داد."</string>
<string name="screen_signout_key_backup_disabled_title">"پشتیبان را خاموش کرده‌اید"</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_offline_title">"کلیدهایتان هنوز در حال پشتیبان گیریند"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"لطفاً پیش از خروج منتظر پایانش شوید."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"لطفاً پیش از خروج منتظر پایانش شوید."</string>
<string name="screen_signout_key_backup_ongoing_title">"کلیدهایتان هنوز در حال پشتیبان گیریند"</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.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
sealed interface VoiceMessageComposerEvents { sealed interface VoiceMessageComposerEvent {
data class RecorderEvent( data class RecorderEvent(
val recorderEvent: VoiceMessageRecorderEvent val recorderEvent: VoiceMessageRecorderEvent
) : VoiceMessageComposerEvents ) : VoiceMessageComposerEvent
data class PlayerEvent( data class PlayerEvent(
val playerEvent: VoiceMessagePlayerEvent, val playerEvent: VoiceMessagePlayerEvent,
) : VoiceMessageComposerEvents ) : VoiceMessageComposerEvent
data object SendVoiceMessage : VoiceMessageComposerEvents data object SendVoiceMessage : VoiceMessageComposerEvent
data object DeleteVoiceMessage : VoiceMessageComposerEvents data object DeleteVoiceMessage : VoiceMessageComposerEvent
data object AcceptPermissionRationale : VoiceMessageComposerEvents data object AcceptPermissionRationale : VoiceMessageComposerEvent
data object DismissPermissionsRationale : VoiceMessageComposerEvents data object DismissPermissionsRationale : VoiceMessageComposerEvent
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvents data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvent
data object DismissSendFailureDialog : VoiceMessageComposerEvents data object DismissSendFailureDialog : VoiceMessageComposerEvent
} }

View file

@ -17,5 +17,5 @@ data class VoiceMessageComposerState(
val showPermissionRationaleDialog: Boolean, val showPermissionRationaleDialog: Boolean,
val showSendFailureDialog: Boolean, val showSendFailureDialog: Boolean,
val keepScreenOn: 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.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings 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.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -136,6 +138,9 @@ class MessagesNode(
onCreate = { onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
}, },
onResume = {
analyticsService.finishLongRunningTransaction(LoadMessagesUi)
},
onDestroy = { onDestroy = {
mediaPlayer.close() 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.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.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -120,7 +120,7 @@ fun MessagesView(
knockRequestsBannerView: @Composable () -> Unit, knockRequestsBannerView: @Composable () -> Unit,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
} }
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn) KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
@ -399,17 +399,17 @@ private fun MessagesViewContent(
if (state.voiceMessageComposerState.showPermissionRationaleDialog) { if (state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog( VoiceMessagePermissionRationaleDialog(
onContinue = { onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
}, },
onDismiss = { onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
}, },
appName = state.appName appName = state.appName
) )
} }
if (state.voiceMessageComposerState.showSendFailureDialog) { if (state.voiceMessageComposerState.showSendFailureDialog) {
VoiceMessageSendingFailedDialog( 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.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
@ -78,19 +78,19 @@ internal fun MessageComposerView(
} }
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent -> val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press)) voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press))
} }
val onSendVoiceMessage = { val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
} }
val onDeleteVoiceMessage = { val onDeleteVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
} }
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent -> val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event)) voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event))
} }
TextComposer( 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.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -86,6 +92,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@AssistedFactory @AssistedFactory
@ -108,6 +115,11 @@ class TimelinePresenter(
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
LaunchedEffect(Unit) {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(DisplayFirstTimelineItems, parent)
}
val localScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() } val timelineMode = remember { timelineController.mainTimelineMode() }
@ -195,6 +207,9 @@ class TimelinePresenter(
focusOnEvent(event.eventId, focusRequestState) focusOnEvent(event.eventId, focusRequestState)
}.start() }.start()
is TimelineEvents.OnFocusEventRender -> { 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() focusRequestState.value = focusRequestState.value.onFocusEventRender()
} }
is TimelineEvents.ClearFocusRequestState -> { is TimelineEvents.ClearFocusRequestState -> {
@ -227,17 +242,27 @@ class TimelinePresenter(
.onEach { newTimelineItems -> .onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems) timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems timelineItems = newTimelineItems
analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems)
finishLongRunningTransaction(OpenRoom)
}
} }
.launchIn(this) .launchIn(this)
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> 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( timelineItemsFactory.replaceWith(
timelineItems = items, timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty() roomMembers = membersState.roomMembers().orEmpty()
) )
transaction?.finish()
items items
} }
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.flowOn(dispatchers.computation)
.launchIn(this) .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.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId 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.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.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
@ -542,7 +542,7 @@ private fun TimelineItemEventRowContent(
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
senderId: UserId, senderId: UserId,
senderProfile: ProfileTimelineDetails, senderProfile: ProfileDetails,
senderAvatar: AvatarData, senderAvatar: AvatarData,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -844,7 +844,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
type = TextMessageType("This is the latest message in the thread", null) type = TextMessageType("This is the latest message in the thread", null)
), ),
senderId = UserId("@user:id"), senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready( senderProfile = ProfileDetails.Ready(
displayName = "Alice", displayName = "Alice",
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = false, displayNameAmbiguous = false,
@ -877,7 +877,7 @@ internal fun ThreadSummaryViewPreview() {
type = TextMessageType(body, null) type = TextMessageType(body, null)
), ),
senderId = UserId("@user:id"), senderId = UserId("@user:id"),
senderProfile = ProfileTimelineDetails.Ready( senderProfile = ProfileDetails.Ready(
displayName = "Alice", displayName = "Alice",
avatarUrl = null, avatarUrl = null,
displayNameAmbiguous = true, 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.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent 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.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.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -63,7 +63,7 @@ class TimelineItemContentFactory(
eventId: EventId?, eventId: EventId?,
isEditable: Boolean, isEditable: Boolean,
sender: UserId, sender: UserId,
senderProfile: ProfileTimelineDetails, senderProfile: ProfileDetails,
): TimelineItemEventContent { ): TimelineItemEventContent {
val isOutgoing = sessionId == sender val isOutgoing = sessionId == sender
return when (itemContent) { 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.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield 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.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.SendHandleProvider
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
@ -69,7 +69,7 @@ sealed interface TimelineItem {
val eventId: EventId? = null, val eventId: EventId? = null,
val transactionId: TransactionId? = null, val transactionId: TransactionId? = null,
val senderId: UserId, val senderId: UserId,
val senderProfile: ProfileTimelineDetails, val senderProfile: ProfileDetails,
val senderAvatar: AvatarData, val senderAvatar: AvatarData,
val content: TimelineItemEventContent, val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L, val sentTimeMillis: Long = 0L,

View file

@ -26,7 +26,7 @@ import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext 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.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -164,25 +164,25 @@ class DefaultVoiceMessageComposerPresenter(
} }
} }
fun handleEvent(event: VoiceMessageComposerEvents) { fun handleEvent(event: VoiceMessageComposerEvent) {
when (event) { when (event) {
is VoiceMessageComposerEvents.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent)
is VoiceMessageComposerEvents.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch { is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch {
sendVoiceMessage() sendVoiceMessage()
} }
VoiceMessageComposerEvents.DeleteVoiceMessage -> { VoiceMessageComposerEvent.DeleteVoiceMessage -> {
player.pause() player.pause()
localCoroutineScope.deleteRecording() localCoroutineScope.deleteRecording()
} }
VoiceMessageComposerEvents.DismissPermissionsRationale -> { VoiceMessageComposerEvent.DismissPermissionsRationale -> {
permissionState.eventSink(PermissionsEvents.CloseDialog) permissionState.eventSink(PermissionsEvents.CloseDialog)
} }
VoiceMessageComposerEvents.AcceptPermissionRationale -> { VoiceMessageComposerEvent.AcceptPermissionRationale -> {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
} }
is VoiceMessageComposerEvents.LifecycleEvent -> handleLifecycleEvent(event.event) is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvents.DismissSendFailureDialog -> { VoiceMessageComposerEvent.DismissSendFailureDialog -> {
showSendFailureDialog = false showSendFailureDialog = false
} }
} }
@ -192,7 +192,10 @@ class DefaultVoiceMessageComposerPresenter(
voiceMessageState = when (val state = recorderState) { voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording( is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime, 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 -> is VoiceRecorderState.Finished ->
previewState( previewState(

View file

@ -42,6 +42,10 @@
<string name="screen_room_timeline_reactions_show_more">"نمایش بیش‌تر"</string> <string name="screen_room_timeline_reactions_show_more">"نمایش بیش‌تر"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"نمایش خلاصهٔ واکنش‌ها"</string> <string name="screen_room_timeline_reactions_show_reactions_summary">"نمایش خلاصهٔ واکنش‌ها"</string>
<string name="screen_room_timeline_read_marker_title">"جدید"</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_action">"پرش به اتاق جدید"</string>
<string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string> <string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string>
<string name="screen_room_timeline_upgraded_room_action">"دیدن پیام‌های قدیمی"</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_objects">"Oggetti"</string>
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</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_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
<string name="emoji_picker_category_symbols">"Simboli"</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_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> <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_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_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_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_optimize_image_quality_title">"Ottimizza la qualità delle immagini"</string>
<string name="screen_media_upload_preview_processing">"Elaborazione…"</string> <string name="screen_media_upload_preview_processing">"Elaborazione…"</string>
<string name="screen_report_content_block_user">"Blocca utente"</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_objects">"Objetos"</string>
<string name="emoji_picker_category_people">"Sorrisos &amp; Pessoas"</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_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="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_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_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_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_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_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_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">"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_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> <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_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string> <string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</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_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_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">"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_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> <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_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">"Bu %1$sni boshlanishi"</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Bu suhbatning 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_less_reactions">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_message_copied">"Xabar nusxalandi"</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> <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_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_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> <string name="screen_room_timeline_read_marker_title">"Yangi"</string>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dxonani almashtirish"</item> <item quantity="one">"%1$dxonani almashtirish"</item>
<item quantity="other">"%1$dxona o\'zgarishi"</item> <item quantity="other">"%1$dxona o\'zgarishi"</item>
</plurals> </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"> <plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s va %3$d boshqalar"</item> <item quantity="one">"%1$s, %2$s va %3$d boshqalar"</item>
<item quantity="other">"%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.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore 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.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -1054,6 +1055,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
analyticsService = FakeAnalyticsService(),
) )
} }
} }

View file

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

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_will_remove_selection">"Oldingi tanlov olib tashlanadi"</string>
<string name="a11y_polls_winning_answer">"Bu g\'alaba qozongan javob"</string> <string name="a11y_polls_winning_answer">"Bu g\'alaba qozongan javob"</string>
</resources> </resources>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Ovozlarni yashirish"</string> <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_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_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_desc">"Savol yoki mavzu"</string>
<string name="screen_create_poll_question_hint">"So\'rovnoma nima haqida?"</string> <string name="screen_create_poll_question_hint">"So\'rovnoma nima haqida?"</string>
<string name="screen_create_poll_title">"Sorovnoma yaratish"</string> <string name="screen_create_poll_title">"Sorovnoma yaratish"</string>

View file

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

View file

@ -31,6 +31,7 @@ class DeveloperSettingsNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToPushHistory() fun navigateToPushHistory()
fun onDone()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -49,7 +50,7 @@ class DeveloperSettingsNode(
modifier = modifier, modifier = modifier,
onOpenShowkase = ::openShowkase, onOpenShowkase = ::openShowkase,
onPushHistoryClick = callback::navigateToPushHistory, 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.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -25,9 +26,18 @@ class LabsNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: LabsPresenter, private val presenter: LabsPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
}
val callback: Callback = callback()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() 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 { sealed interface EditUserProfileEvents {
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
data class UpdateDisplayName(val name: String) : EditUserProfileEvents data class UpdateDisplayName(val name: String) : EditUserProfileEvents
data object Exit : EditUserProfileEvents
data object Save : 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 dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs 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.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -27,22 +28,32 @@ class EditUserProfileNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory, presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins),
EditUserProfileNavigator {
data class Inputs( data class Inputs(
val matrixUser: MatrixUser val matrixUser: MatrixUser
) : NodeInputs ) : NodeInputs
interface Callback : Plugin {
fun onDone()
}
val matrixUser = inputs<Inputs>().matrixUser val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser) val callback: Callback = callback()
val presenter = presenterFactory.create(
matrixUser = matrixUser,
navigator = this,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
EditUserProfileView( EditUserProfileView(
state = state, state = state,
onBackClick = ::navigateUp, onEditProfileSuccess = ::close,
onEditProfileSuccess = ::navigateUp,
modifier = modifier modifier = modifier
) )
} }
override fun close() = callback.onDone()
} }

View file

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

View file

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

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.user.editprofile 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -30,11 +31,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R 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.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults 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.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType 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.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.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun EditUserProfileView( fun EditUserProfileView(
state: EditUserProfileState, state: EditUserProfileState,
onBackClick: () -> Unit,
onEditProfileSuccess: () -> Unit, onEditProfileSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -64,12 +66,21 @@ fun EditUserProfileView(
isAvatarActionsSheetVisible.value = true isAvatarActionsSheetVisible.value = true
} }
fun onBackClick() {
focusManager.clearFocus()
state.eventSink(EditUserProfileEvents.Exit)
}
BackHandler(
enabled = true,
::onBackClick,
)
Scaffold( Scaffold(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
TopAppBar( TopAppBar(
titleStr = stringResource(R.string.screen_edit_profile_title), titleStr = stringResource(R.string.screen_edit_profile_title),
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = { BackButton(::onBackClick) },
actions = { actions = {
TextButton( TextButton(
text = stringResource(CommonStrings.action_save), text = stringResource(CommonStrings.action_save),
@ -132,10 +143,20 @@ fun EditUserProfileView(
progressText = stringResource(R.string.screen_edit_profile_updating_details), 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() }, onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
errorMessage = { stringResource(R.string.screen_edit_profile_error) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) },
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
) )
} }
PermissionsView( PermissionsView(
@ -148,7 +169,6 @@ fun EditUserProfileView(
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreview { ElementPreview {
EditUserProfileView( EditUserProfileView(
onBackClick = {},
onEditProfileSuccess = {}, onEditProfileSuccess = {},
state = state, 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_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_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_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_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_media_compression_title">"Ottimizza la qualità dei contenuti multimediali"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderazione e Sicurezza"</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_error_title">"Impossibile aggiornare il profilo"</string>
<string name="screen_edit_profile_title">"Modifica 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_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_additional_settings_section_title">"Impostazioni aggiuntive"</string>
<string name="screen_notification_settings_calls_label">"Chiamate audio e video"</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> <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_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_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_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_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_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_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_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_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> <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_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_title">"Editar perfil"</string>
<string name="screen_edit_profile_updating_details">"Atualizando o 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_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_calls_label">"Chamadas de áudio e vídeo"</string>
<string name="screen_notification_settings_configuration_mismatch">"Não correspondência de configuração"</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">"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_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_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_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_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_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_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">"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_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">"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_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_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_empty">"Sizda bloklangan foydalanuvchi yoq"</string>
<string name="screen_blocked_users_unblock_alert_action">"Blokdan chiqarish"</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_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_system_notifications_turned_off">"Tizim bildirishnomalari o\'chirilgan"</string>
<string name="screen_notification_settings_title">"Bildirishnomalar"</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_section">"Muammolarni bartaraf etish"</string>
<string name="troubleshoot_notifications_entry_point_title">"Bildirishnomalar bilan bogliq muammolarni bartaraf etish"</string> <string name="troubleshoot_notifications_entry_point_title">"Bildirishnomalar bilan bogliq muammolarni bartaraf etish"</string>
</resources> </resources>

View file

@ -44,6 +44,10 @@
<string name="screen_edit_profile_error_title">"无法更新个人资料"</string> <string name="screen_edit_profile_error_title">"无法更新个人资料"</string>
<string name="screen_edit_profile_title">"编辑个人资料"</string> <string name="screen_edit_profile_title">"编辑个人资料"</string>
<string name="screen_edit_profile_updating_details">"更新个人资料……"</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_labs_title">"实验室"</string>
<string name="screen_notification_settings_additional_settings_section_title">"更多设置"</string> <string name="screen_notification_settings_additional_settings_section_title">"更多设置"</string>
<string name="screen_notification_settings_calls_label">"音视频通话"</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