Merge branch 'release/0.7.6' into main

This commit is contained in:
ganfra
2024-12-20 14:40:55 +01:00
771 changed files with 14808 additions and 2464 deletions

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' }}
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
uses: nschloe/action-cached-lfs-checkout@v1.2.3
- name: Use JDK 21
uses: actions/setup-java@v4
with:

View File

@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository == 'element-hq/element-x-android' }}
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
uses: nschloe/action-cached-lfs-checkout@v1.2.3
- name: Use JDK 21
uses: actions/setup-java@v4

View File

@@ -24,13 +24,13 @@ jobs:
labels: Record-Screenshots
- name: ⏬ Checkout with LFS (PR)
if: github.event.label.name == 'Record-Screenshots'
uses: nschloe/action-cached-lfs-checkout@v1.2.2
uses: nschloe/action-cached-lfs-checkout@v1.2.3
with:
persist-credentials: false
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
- name: ⏬ Checkout with LFS (Branch)
if: github.event_name == 'workflow_dispatch'
uses: nschloe/action-cached-lfs-checkout@v1.2.2
uses: nschloe/action-cached-lfs-checkout@v1.2.3
with:
persist-credentials: false
- name: ☕️ Use JDK 21

View File

@@ -33,7 +33,7 @@ jobs:
sudo swapon /mnt/swapfile
sudo swapon --show
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
uses: nschloe/action-cached-lfs-checkout@v1.2.3
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: nschloe/action-cached-lfs-checkout@v1.2.2
- uses: nschloe/action-cached-lfs-checkout@v1.2.3
- run: |
./tools/git/validate_lfs.sh

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
<option name="version" value="2.1.0" />
</component>
</project>

View File

@@ -30,5 +30,6 @@ appId: ${MAESTRO_APP_ID}
# assert there's 1 member and 2 invitees
- tapOn: "Back"
- scroll
- scroll
- tapOn: "Leave room"
- tapOn: "Leave"

View File

@@ -1,3 +1,72 @@
Changes in Element X v0.7.5 (2024-12-06)
========================================
## What's Changed
### ✨ Features
* Allow to set caption when uploading file and audio files, and allow adding / edit / remove caption on Event with attachment (also works on local echo) by @bmarty in https://github.com/element-hq/element-x-android/pull/3902
* Enable all notification actions: quick reply, accept/decline invite, mark as read from notification. by @bmarty in https://github.com/element-hq/element-x-android/pull/3916
* Video player controller by @bmarty in https://github.com/element-hq/element-x-android/pull/3959
### 🙌 Improvements
* change : confirm biometric before allowing biometric unlock. by @ganfra in https://github.com/element-hq/element-x-android/pull/3930
* Hide media preprocessing by @bmarty in https://github.com/element-hq/element-x-android/pull/3943
* changes: iterate on room create screen by @ganfra in https://github.com/element-hq/element-x-android/pull/3966
* change : knock message supporting text display number of characters by @ganfra in https://github.com/element-hq/element-x-android/pull/3970
* feat(design) : update send button background by @ganfra in https://github.com/element-hq/element-x-android/pull/4000
### 🐛 Bugfixes
* Min size for hidden media by @bmarty in https://github.com/element-hq/element-x-android/pull/3906
* fix : use RoomMembershipObserver to close room screen when leaving by @ganfra in https://github.com/element-hq/element-x-android/pull/3887
* fix : protect some usages of client to avoid crashes by @bmarty in https://github.com/element-hq/element-x-android/pull/3886
* Fix long click not working on pinned events timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3940
* Element Call: display error dialog only when loading the main URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3962
* Fix navigation issue when entering recovery key after navigating from the banner by @bmarty in https://github.com/element-hq/element-x-android/pull/3961
* navigation : clear backstack when opening room from outer node by @ganfra in https://github.com/element-hq/element-x-android/pull/3984
* fix : hide keyboard when TextComposer is removed from composition by @ganfra in https://github.com/element-hq/element-x-android/pull/3985
* fix(room_preview) : catch all exception instead by @ganfra in https://github.com/element-hq/element-x-android/pull/3989
* fix(room_detail) : hide room avatar preview by @ganfra in https://github.com/element-hq/element-x-android/pull/3992
* fix(composer) : use HideKeyboardWhenDisposed only in MessagesView by @ganfra in https://github.com/element-hq/element-x-android/pull/3993
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3936
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3975
### Dependency upgrades
* Update dependency io.sentry:sentry-android to v7.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3891
* Update plugin sonarqube to v6 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/3895
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.64 by @renovate in https://github.com/element-hq/element-x-android/pull/3907
* Update dependency com.autonomousapps.dependency-analysis to v2.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3909
* Update dependency org.robolectric:robolectric to v4.14.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3924
* Update dependency io.element.android:compound-android to v0.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3915
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.65 by @renovate in https://github.com/element-hq/element-x-android/pull/3932
* Update media3 to v1.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3942
* Update plugin ktlint to v12.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3944
* Update wysiwyg to v2.37.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3948
* Update mobile-dev-inc/action-maestro-cloud action to v1.9.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3914
* Update dependency com.lemonappdev:konsist to v0.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3947
* deps : update rust sdk to 0.2.67 and fix breaking changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3957
* Update dependency com.lemonappdev:konsist to v0.17.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3983
* Update plugin sonarqube to v6.0.1.5171 by @renovate in https://github.com/element-hq/element-x-android/pull/3958
* Update dagger to v2.53 by @renovate in https://github.com/element-hq/element-x-android/pull/3986
* Update dependency com.sigpwned:emoji4j-core to v16 by @renovate in https://github.com/element-hq/element-x-android/pull/3899
* dependencies : update rust sdk to 0.2.68 by @ganfra in https://github.com/element-hq/element-x-android/pull/3988
* Update plugin dependencycheck to v11.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3994
* chore(dependencies) : update rust sdk to 0.2.69 by @ganfra in https://github.com/element-hq/element-x-android/pull/3999
### Others
* Send button iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3901
* Fix photo / video name by @bmarty in https://github.com/element-hq/element-x-android/pull/3903
* Render edited caption. by @bmarty in https://github.com/element-hq/element-x-android/pull/3904
* Rely on the SDK to decide if a caption is editable or not by @bmarty in https://github.com/element-hq/element-x-android/pull/3917
* Remove AttachmentsState and use the MessagesNavigator by @bmarty in https://github.com/element-hq/element-x-android/pull/3918
* Fix element call crash when resuming from notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3926
* Ensure that the SDK is syncing during an incoming call so that the app can cancel the notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3931
* Add feature flag to temporary disable sending caption by default in production by @bmarty in https://github.com/element-hq/element-x-android/pull/3953
* Add timeline action item to copy caption by @bmarty in https://github.com/element-hq/element-x-android/pull/3963
* Fix wrong name of classes and method by @bmarty in https://github.com/element-hq/element-x-android/pull/3971
* Rework on media module by @bmarty in https://github.com/element-hq/element-x-android/pull/3967
* Add warning when adding a caption. by @bmarty in https://github.com/element-hq/element-x-android/pull/3977
* Do not auto-play videos. by @bmarty in https://github.com/element-hq/element-x-android/pull/3978
* MediaViewer: iterate on design by @bmarty in https://github.com/element-hq/element-x-android/pull/3979
* feat(crypto): Support new expected UTD causes UX + Analytics by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3980
* increase ringing timeout from 15 seconds to 90 seconds by @fkwp in https://github.com/element-hq/element-x-android/pull/3991
* MediaViewer: Align title to left and move action bottom to top bar. by @bmarty in https://github.com/element-hq/element-x-android/pull/4003
Changes in Element X v0.7.4 (2024-11-20)
========================================

View File

@@ -59,7 +59,7 @@ Element X Android supports many languages. You can help us to translate the app
Note that for now, we keep control on the French and German translations.
Translations can be checked screen per screen using our tool Element X Android Gallery, available at https://element-hq.github.io/element-x-android/. Note that this page is updated every Tuesday.
Translations can be checked screen per screen using our tool Element X Android Gallery, available at https://element-hq.github.io/element-x-android/. Note that this page is updated every Tuesday.
More instructions about translating the application can be found at [CONTRIBUTING.md](CONTRIBUTING.md#strings).
@@ -83,8 +83,11 @@ You can also come chat with the community in the Matrix [room](https://matrix.to
## Build instructions
Just clone the project and open it in Android Studio.
Makes sure to select the `app` configuration when building (as we also have sample apps in the project).
Just clone the project and open it in Android Studio. Make sure to select the
`app` configuration when building (as we also have sample apps in the project).
To build against a local copy of the Rust SDK, see the [Developer
onboarding](docs/_developer_onboarding.md#build-the-sdk-locally) instructions.
## Support

View File

@@ -88,6 +88,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -196,6 +197,10 @@ class LoggedInFlowNode @AssistedInject constructor(
) { syncState, networkStatus ->
Pair(syncState, networkStatus)
}
.onStart {
// Temporary fix to ensure that the sync is started even if the networkStatus is offline.
syncService.startSync()
}
.collect { (syncState, networkStatus) ->
Timber.d("Sync state: $syncState, network status: $networkStatus")
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {

View File

@@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {

View File

@@ -102,8 +102,8 @@ From these kotlin bindings we can generate native libs (.so files) and kotlin cl
#### Matrix Rust Component Kotlin
To use these bindings in an android project, we need to wrap this up into an android library (as the form of an .aar file).
This is the goal of https://github.com/matrix-org/matrix-rust-components-kotlin.
To use these bindings in an android project, we need to wrap this up into an android library (as the form of an .aar file).
This is the goal of https://github.com/matrix-org/matrix-rust-components-kotlin.
This repository is used for distributing kotlin releases of the Matrix Rust SDK.
It'll provide the corresponding aar and also publish them on maven.
@@ -117,41 +117,43 @@ You can also have access to the aars through the [release](https://github.com/ma
#### Build the SDK locally
Easiest way: run the script [../tools/sdk/build_rust_sdk.sh](../tools/sdk/build_rust_sdk.sh) and just answer the questions.
Prerequisites:
* Install the Android NDK (Native Development Kit). To do this from within
Android Studio:
1. **Tools > SDK Manager**
2. Click the **SDK Tools** tab.
3. Select the **NDK (Side by side)** checkbox
4. Click **OK**.
5. Click **OK**.
6. When the installation is complete, click **Finish**.
* Install `cargo-ndk`:
```
cargo install cargo-ndk
```
* Install the Android Rust toolchain for your machine's hardware:
```
rustup target add aarch64-linux-android x86_64-linux-android
```
* Depending on the location of the Android SDK, you may need to set
`ANDROID_HOME`:
```
export ANDROID_HOME=$HOME/android/sdk
```
Legacy way:
You can then build the Rust SDK by running the script
[`tools/sdk/build_rust_sdk.sh`](../tools/sdk/build_rust_sdk.sh) and just answering
the questions.
If you need to locally build the sdk-android you can use
the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script.
For this please check the [prerequisites](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/README.md#prerequisites) from the repo.
Checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
```shell
git clone git@github.com:matrix-org/matrix-rust-sdk.git
git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git
```
Then you can launch the build script from the matrix-rust-components-kotlin repository with the following params:
- `-p` Local path to the rust-sdk repository
- `-o` Optional output path with the expected name of the aar file. By default the aar will be located in the corresponding build/outputs/aar directory.
- `-r` Flag to build in release mode
- `-m` Option to select the gradle module to build. Default is sdk.
- `-t` Option to to select an android target to build against. Default will build for all targets.
So for example to build the sdk against aarch64-linux-android target and copy the generated aar to Element X project:
```shell
./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar
```
This will prompt you for the path to the Rust SDK, then build it and
`matrix-rust-components-kotlin`, eventually producing an aar file at
`./libraries/rustsdk/matrix-rust-sdk.aar`, which will be picked up
automatically by the Element X Android build.
Troubleshooting:
- You may need to set `ANDROID_NDK_HOME` e.g `export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk`.
- If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version.
- If you get the error `Unsupported class file major version 64` try changing your JVM version. In this case, Java 20 is not supported in Gradle yet, so downgrade to an earlier version (Java 17 worked in this case).
You are good to test your local rust development now!
- If you get the error `Unsupported class file major version <n>`, try changing your JVM version by setting
`JAVA_HOME` and, if building via Android Studio, "File | Settings | Build, Execution, Deployment | Build Tools | Gradle | Gradle JDK".
### The Android project
@@ -262,7 +264,7 @@ Here are the main points:
#### Template and naming
This documentation provides you with the steps to install and use the AS plugin for generating modules in your project.
This documentation provides you with the steps to install and use the AS plugin for generating modules in your project.
The plugin and templates will help you quickly create new features with a standardized structure.
A. Installation
@@ -276,7 +278,7 @@ Follow these steps to install and configure the plugin and templates:
- Navigate to File/Manage IDE Settings/Import Settings
- Pick the `tmp/file_templates.zip` files
- Click on OK
4. Configure generate-module-from-template plugin :
4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings
- Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json`
@@ -296,9 +298,9 @@ Example for a new feature called RoomDetails:
5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
suffix `Presenter`,states MUST have a suffix `State`, etc. Also we want to have a common naming along all the modules.

View File

@@ -0,0 +1,2 @@
Main changes in this version: media browser and bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View File

@@ -13,6 +13,8 @@ Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_room_access_section_header">"Szobahozzáférés"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Egyes karakterek nem engedélyezettek. Csak a betűk, a számjegyek és a következő szimbólumok támogatottak: $ &amp; \'() * +/; =? @ [] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"Ez a szobacím már létezik. Próbálja meg szerkeszteni a szobacím mezőt, vagy módosítsa a szoba nevét."</string>
<string name="screen_create_room_room_address_section_footer">"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."</string>
<string name="screen_create_room_room_address_section_title">"Szoba címe"</string>
<string name="screen_create_room_room_name_label">"Szoba neve"</string>

View File

@@ -3,11 +3,22 @@
<string name="screen_create_room_action_create_room">"Nuova stanza"</string>
<string name="screen_create_room_add_people_title">"Invita persone"</string>
<string name="screen_create_room_error_creating_room">"Si è verificato un errore durante la creazione della stanza"</string>
<string name="screen_create_room_private_option_description">"I messaggi in questa stanza sono cifrati. La crittografia non può essere disattivata in seguito."</string>
<string name="screen_create_room_private_option_title">"Stanza privata (solo su invito)"</string>
<string name="screen_create_room_public_option_description">"I messaggi non sono cifrati e chiunque può leggerli. Puoi attivare la crittografia in un secondo momento."</string>
<string name="screen_create_room_public_option_title">"Stanza pubblica (chiunque)"</string>
<string name="screen_create_room_private_option_description">"Solo le persone invitate possono accedere a questa stanza. Tutti i messaggi sono cifrati end-to-end."</string>
<string name="screen_create_room_private_option_title">"Stanza privata"</string>
<string name="screen_create_room_public_option_description">"Chiunque può trovare questa stanza.
Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza."</string>
<string name="screen_create_room_public_option_title">"Stanza pubblica"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Chiunque può entrare in questa stanza"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Chiunque"</string>
<string name="screen_create_room_room_access_section_header">"Accesso alla stanza"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Chiunque può chiedere di entrare nella stanza, ma un amministratore o un moderatore dovrà accettare la richiesta"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Chiedi di entrare"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Alcuni caratteri non sono consentiti. Sono supportate solo lettere, cifre e i seguenti simboli ! $ &amp; \'() * +/; =? @ [] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"L\'indirizzo di questa stanza esiste già. Prova a modificare il campo dell\'indirizzo o a cambiare il nome della stanza"</string>
<string name="screen_create_room_room_address_section_footer">"Affinché questa stanza sia visibile nell\'elenco delle stanze pubbliche, è necessario un indirizzo della stanza."</string>
<string name="screen_create_room_room_address_section_title">"Indirizzo della stanza"</string>
<string name="screen_create_room_room_name_label">"Nome stanza"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilità della stanza"</string>
<string name="screen_create_room_title">"Crea una stanza"</string>
<string name="screen_create_room_topic_label">"Argomento (facoltativo)"</string>
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>

View File

@@ -3,7 +3,7 @@
<string name="screen_deactivate_account_confirmation_dialog_content">"Veuillez confirmer que vous souhaitez désactiver votre compte. Cette action ne peut pas être annulée."</string>
<string name="screen_deactivate_account_delete_all_messages">"Supprimer tous mes messages"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Attention : les futurs utilisateurs pourraient voir des conversations incomplètes."</string>
<string name="screen_deactivate_account_description">"La désactivation de votre compte est %1$s, cela va:"</string>
<string name="screen_deactivate_account_description">"La désactivation de votre compte est %1$s, cela va :"</string>
<string name="screen_deactivate_account_description_bold_part">"irréversible"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s votre compte (vous ne pourrez plus vous reconnecter et votre identifiant ne pourra pas être réutilisé)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Désactiver définitivement"</string>

View File

@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Annuler la demande"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Oui, annuler"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Êtes-vous sûr de vouloir annuler votre demande daccès à ce salon?"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Êtes-vous sûr de vouloir annuler votre demande daccès à ce salon ?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Annuler la demande dadhésion"</string>
<string name="screen_join_room_join_action">"Rejoindre"</string>
<string name="screen_join_room_knock_action">"Demander à joindre"</string>
@@ -13,6 +13,6 @@
<string name="screen_join_room_space_not_supported_title">"Les Spaces ne sont pas encore pris en charge"</string>
<string name="screen_join_room_subtitle_knock">"Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion."</string>
<string name="screen_join_room_subtitle_no_preview">"Vous devez être un membre du salon pour pouvoir lire lhistorique des messages."</string>
<string name="screen_join_room_title_knock">"Vous souhaitez rejoindre ce salon?"</string>
<string name="screen_join_room_title_knock">"Vous souhaitez rejoindre ce salon ?"</string>
<string name="screen_join_room_title_no_preview">"La prévisualisation nest pas disponible"</string>
</resources>

View File

@@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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_description">"Sei sicuro di voler annullare la tua richiesta di accesso a questa stanza?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Annulla la richiesta di accesso"</string>
<string name="screen_join_room_join_action">"Entra nella stanza"</string>
<string name="screen_join_room_knock_action">"Bussa per partecipare"</string>
<string name="screen_join_room_knock_message_description">"Messaggio (opzionale)"</string>
<string name="screen_join_room_knock_sent_description">"Riceverai un invito a entrare nella stanza se la tua richiesta viene accettata."</string>
<string name="screen_join_room_knock_sent_title">"Richiesta di accesso inviata"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s non supporta ancora gli spazi. Puoi accedere agli spazi sul web."</string>
<string name="screen_join_room_space_not_supported_title">"Gli spazi non sono ancora supportati"</string>
<string name="screen_join_room_subtitle_knock">"Clicca sul pulsante qui sotto e un amministratore della stanza riceverà una notifica. Potrai partecipare alla conversazione una volta approvato."</string>

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.knockrequests.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.api.banner
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
interface KnockRequestsBannerRenderer {
@Composable
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.api.list
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface KnockRequestsListEntryPoint : SimpleFeatureEntryPoint

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
import extension.setupAnvil
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.knockrequests.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
dependencies {
api(projects.features.knockrequests.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.featureflag.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer
import io.element.android.libraries.di.RoomScope
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultKnockRequestsBannerRenderer @Inject constructor(
private val presenter: KnockRequestsBannerPresenter,
) : KnockRequestsBannerRenderer {
@Composable
override fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) {
val state = presenter.present()
KnockRequestsBannerView(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
sealed interface KnockRequestsBannerEvents {
data object AcceptSingleRequest : KnockRequestsBannerEvents
data object Dismiss : KnockRequestsBannerEvents
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.extensions.firstIfSingle
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
class KnockRequestsBannerPresenter @Inject constructor(
private val knockRequestsService: KnockRequestsService,
private val appCoroutineScope: CoroutineScope,
) : Presenter<KnockRequestsBannerState> {
@Composable
override fun present(): KnockRequestsBannerState {
val knockRequests by remember {
knockRequestsService.knockRequestsFlow.mapState { knockRequests ->
knockRequests.dataOrNull().orEmpty()
.filter { !it.isSeen }
.toImmutableList()
}
}.collectAsState()
val permissions by knockRequestsService.permissionsFlow.collectAsState()
val showAcceptError = remember { mutableStateOf(false) }
val shouldShowBanner by remember {
derivedStateOf {
permissions.canHandle && knockRequests.isNotEmpty()
}
}
fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> {
appCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests,
displayAcceptError = showAcceptError,
)
}
is KnockRequestsBannerEvents.Dismiss -> {
appCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen()
}
}
}
}
return KnockRequestsBannerState(
knockRequests = knockRequests,
displayAcceptError = showAcceptError.value,
canAccept = permissions.canAccept,
isVisible = shouldShowBanner,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.acceptSingleKnockRequest(
knockRequests: List<KnockRequestPresentable>,
displayAcceptError: MutableState<Boolean>,
) = launch {
val knockRequest = knockRequests.firstIfSingle()
if (knockRequest != null) {
knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true)
.onFailure {
displayAcceptError.value = true
delay(ACCEPT_ERROR_DISPLAY_DURATION)
displayAcceptError.value = false
}
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.core.extensions.firstIfSingle
import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsBannerState(
val isVisible: Boolean,
val knockRequests: ImmutableList<KnockRequestPresentable>,
val displayAcceptError: Boolean,
val canAccept: Boolean,
val eventSink: (KnockRequestsBannerEvents) -> Unit,
) {
val subtitle = knockRequests.firstIfSingle()?.userId?.value
val reason = knockRequests.firstIfSingle()?.reason
@Composable
fun formattedTitle(): String {
return when (knockRequests.size) {
0 -> ""
1 -> stringResource(R.string.screen_room_single_knock_request_title, knockRequests.first().getBestName())
else -> {
val firstRequest = knockRequests.first()
val otherRequestsCount = knockRequests.size - 1
pluralStringResource(
id = R.plurals.screen_room_multiple_knock_requests_title,
count = otherRequestsCount,
firstRequest.getBestName(),
otherRequestsCount
)
}
}
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import kotlinx.collections.immutable.toImmutableList
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
override val values: Sequence<KnockRequestsBannerState>
get() = sequenceOf(
aKnockRequestsBannerState(),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," +
"very amazing reason, I know, right, I'm so good at writing reasons."
)
)
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(),
aKnockRequestPresentable(displayName = "Alice")
)
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(),
aKnockRequestPresentable(displayName = "Alice"),
aKnockRequestPresentable(displayName = "Bob"),
aKnockRequestPresentable(displayName = "Charlie")
)
),
aKnockRequestsBannerState(
canAccept = false
),
aKnockRequestsBannerState(
displayAcceptError = true
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(
displayName = "A_very_long_display_name_so_that_the_text_can_be_displayed_on_multiple_lines"
)
)
),
)
}
fun aKnockRequestsBannerState(
knockRequests: List<KnockRequestPresentable> = listOf(aKnockRequestPresentable()),
displayAcceptError: Boolean = false,
canAccept: Boolean = true,
isVisible: Boolean = true,
eventSink: (KnockRequestsBannerEvents) -> Unit = {}
) = KnockRequestsBannerState(
knockRequests = knockRequests.toImmutableList(),
displayAcceptError = displayAcceptError,
canAccept = canAccept,
isVisible = isVisible,
eventSink = eventSink,
)

View File

@@ -0,0 +1,262 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
private const val MAX_AVATAR_COUNT = 3
@Composable
fun KnockRequestsBannerView(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
AnimatedVisibility(
visible = state.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = ElementTheme.colors.bgCanvasDefaultLevel1,
shadowElevation = 24.dp,
modifier = Modifier.padding(16.dp),
) {
KnockRequestsBannerContent(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}
KnockRequestsAcceptErrorView(displayError = state.displayAcceptError)
}
}
@Composable
private fun KnockRequestsAcceptErrorView(
displayError: Boolean,
modifier: Modifier = Modifier,
) {
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = modifier.statusBarsPadding(), state = asyncIndicatorState)
LaunchedEffect(displayError) {
if (displayError) {
asyncIndicatorState.enqueue {
AsyncIndicator.Custom(text = stringResource(CommonStrings.error_unknown))
}
} else {
asyncIndicatorState.clear()
}
}
}
@Composable
private fun KnockRequestsBannerContent(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onDismissClick() {
state.eventSink(KnockRequestsBannerEvents.Dismiss)
}
fun onAcceptClick() {
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
}
Column(
modifier
.fillMaxWidth()
.padding(all = 16.dp)
) {
Row {
KnockRequestAvatarView(
state.knockRequests,
modifier = Modifier.padding(top = 2.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.formattedTitle(),
style = ElementTheme.typography.fontBodyMdMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Start,
)
if (state.subtitle != null) {
Text(
text = state.subtitle,
style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.clickable(onClick = ::onDismissClick),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close)
)
}
val reason = state.reason
if (!reason.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.reason,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (state.knockRequests.size > 1) {
Button(
text = stringResource(R.string.screen_room_multiple_knock_requests_view_all_button_title),
onClick = onViewRequestsClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
} else {
OutlinedButton(
text = stringResource(R.string.screen_room_single_knock_request_view_button_title),
onClick = onViewRequestsClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
if (state.canAccept) {
Button(
text = stringResource(R.string.screen_room_single_knock_request_accept_button_title),
onClick = ::onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun KnockRequestAvatarView(
knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier,
) {
Box(modifier) {
when (knockRequests.size) {
0 -> Unit
1 -> Avatar(knockRequests.first().getAvatarData(AvatarSize.KnockRequestBanner))
else -> KnockRequestAvatarListView(knockRequests)
}
}
}
@Composable
private fun KnockRequestAvatarListView(
knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier,
) {
val avatarSize = AvatarSize.KnockRequestBanner.dp
Box(
modifier = modifier,
) {
knockRequests
.take(MAX_AVATAR_COUNT)
.reversed()
.let { smallReversedList ->
val lastItemIndex = smallReversedList.size - 1
smallReversedList.forEachIndexed { index, knockRequest ->
Avatar(
modifier = Modifier
.padding(start = avatarSize / 2 * (lastItemIndex - index))
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left.
drawContent()
if (index < lastItemIndex) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = size.height / 2,
),
radius = avatarSize.toPx() / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
.padding(2.dp),
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
)
}
}
}
}
@Composable
@PreviewsDayNight
internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview {
KnockRequestsBannerView(
state = state,
onViewRequestsClick = {},
)
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
fun aKnockRequestPresentable(
eventId: EventId = EventId("\$eventId"),
userId: UserId = UserId("@jacob_ross:example.com"),
displayName: String? = "Jacob Ross",
avatarUrl: String? = null,
reason: String? = "Hi, I would like to get access to this room please.",
formattedDate: String? = "20 Nov 2024",
) = object : KnockRequestPresentable {
override val eventId: EventId = eventId
override val userId: UserId = userId
override val displayName: String? = displayName
override val avatarUrl: String? = avatarUrl
override val reason: String? = reason
override val formattedDate: String? = formattedDate
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
data class KnockRequestPermissions(
val canAccept: Boolean,
val canDecline: Boolean,
val canBan: Boolean,
) {
val canHandle = canAccept || canDecline || canBan
}
fun MatrixRoom.knockRequestPermissionsFlow(): Flow<KnockRequestPermissions> {
return syncUpdateFlow.map {
val canAccept = canInvite().getOrDefault(false)
val canDecline = canKick().getOrDefault(false)
val canBan = canBan().getOrDefault(false)
KnockRequestPermissions(canAccept, canDecline, canBan)
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Immutable
interface KnockRequestPresentable {
val eventId: EventId
val userId: UserId
val displayName: String?
val avatarUrl: String?
val reason: String?
val formattedDate: String?
fun getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = size,
)
fun getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
class KnockRequestWrapper(
private val inner: KnockRequest,
dateFormatter: (Long?) -> String? = { null }
) : KnockRequestPresentable {
override val eventId: EventId = inner.eventId
override val userId: UserId = inner.userId
override val displayName: String? = inner.displayName
override val avatarUrl: String? = inner.avatarUrl
override val reason: String? = inner.reason?.trim()
override val formattedDate: String? = dateFormatter(inner.timestamp)
val isSeen: Boolean = inner.isSeen
suspend fun accept(): Result<Unit> = inner.accept()
suspend fun decline(reason: String?): Result<Unit> = inner.decline(reason)
suspend fun declineAndBan(reason: String?): Result<Unit> = inner.declineAndBan(reason)
suspend fun markAsSeen(): Result<Unit> = inner.markAsSeen()
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
sealed class KnockRequestsException : Exception() {
data object AcceptAllPartiallyFailed : KnockRequestsException()
data object KnockRequestNotFound : KnockRequestsException()
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
@Module
@ContributesTo(RoomScope::class)
object KnockRequestsModule {
@Provides
@SingleIn(RoomScope::class)
fun knockRequestsService(room: MatrixRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow,
permissionsFlow = room.knockRequestPermissionsFlow(),
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
coroutineScope = room.roomCoroutineScope
)
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.supervisorScope
class KnockRequestsService(
knockRequestsFlow: Flow<List<KnockRequest>>,
permissionsFlow: Flow<KnockRequestPermissions>,
isKnockFeatureEnabledFlow: Flow<Boolean>,
coroutineScope: CoroutineScope,
) {
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
private val handledKnockRequestIds = MutableStateFlow<Set<EventId>>(emptySet())
val knockRequestsFlow = combine(
isKnockFeatureEnabledFlow,
knockRequestsFlow,
handledKnockRequestIds,
) { isKnockEnabled, knockRequests, handledKnockIds ->
if (!isKnockEnabled) {
AsyncData.Success(persistentListOf())
} else {
val presentableKnockRequests = knockRequests
.filter { it.eventId !in handledKnockIds }
.map { inner -> KnockRequestWrapper(inner) }
.toImmutableList()
AsyncData.Success(presentableKnockRequests)
}
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
val permissionsFlow = permissionsFlow.stateIn(
scope = coroutineScope,
started = SharingStarted.Lazily,
initialValue = KnockRequestPermissions(canAccept = false, canDecline = false, canBan = false)
)
private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty()
private fun getKnockRequestById(eventId: EventId): KnockRequestWrapper? {
return knockRequestsList().find { it.eventId == eventId }
}
/**
* Accept a knock request.
* @param knockRequest The knock request to accept.
* @param optimistic If true, the request will be marked as handled before the server responds.
*/
suspend fun acceptKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result<Unit> {
val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult()
return handleKnockRequest(wrapped, optimistic) { accept() }
}
/**
* Decline a knock request.
* @param knockRequest The knock request to decline.
* @param optimistic If true, the request will be marked as handled before the server responds.
*/
suspend fun declineKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result<Unit> {
val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult()
return handleKnockRequest(wrapped, optimistic) { decline(null) }
}
/**
* Decline a knock request by banning the user.
* @param knockRequest The knock request to decline.
* @param optimistic If true, the request will be marked as handled before the server responds.
*/
suspend fun declineAndBanKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result<Unit> {
val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult()
return handleKnockRequest(wrapped, optimistic) { declineAndBan(null) }
}
/**
* Accept all currently known knock requests.
* @param optimistic If true, the requests will be marked as handled before the server responds.
*/
suspend fun acceptAllKnockRequests(optimistic: Boolean = false): Result<Unit> = supervisorScope {
val results = knockRequestsList()
.map { knockRequest ->
async {
acceptKnockRequest(knockRequest, optimistic = optimistic)
}
}
.awaitAll()
if (results.all { it.isSuccess }) {
Result.success(Unit)
} else {
Result.failure(KnockRequestsException.AcceptAllPartiallyFailed)
}
}
/**
* Mark all currently known knock requests as seen.
*/
suspend fun markAllKnockRequestsAsSeen() = supervisorScope {
knockRequestsList()
.map { knockRequest ->
async { knockRequest.markAsSeen() }
}
.awaitAll()
}
private suspend fun handleKnockRequest(
knockRequest: KnockRequestWrapper,
optimistic: Boolean,
action: suspend (KnockRequestWrapper.() -> Result<Unit>)
): Result<Unit> {
if (optimistic) {
handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId }
}
return action(knockRequest)
.onFailure {
if (optimistic) {
handledKnockRequestIds.getAndUpdate { it - knockRequest.eventId }
}
}
.onSuccess {
if (!optimistic) {
handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId }
}
}
}
}
private fun knockRequestNotFoundResult() = Result.failure<Unit>(KnockRequestsException.KnockRequestNotFound)

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultKnockRequestsListEntryPoint @Inject constructor() : KnockRequestsListEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<KnockRequestsListNode>(buildContext)
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
sealed interface KnockRequestsListEvents {
data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data object AcceptAll : KnockRequestsListEvents
data object ResetCurrentAction : KnockRequestsListEvents
data object RetryCurrentAction : KnockRequestsListEvents
data object ConfirmCurrentAction : KnockRequestsListEvents
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class KnockRequestsListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: KnockRequestsListPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
KnockRequestsListView(
state = state,
onBackClick = ::navigateUp,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class KnockRequestsListPresenter @Inject constructor(
private val knockRequestsService: KnockRequestsService,
) : Presenter<KnockRequestsListState> {
@Composable
override fun present(): KnockRequestsListState {
val asyncAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
var currentAction by remember { mutableStateOf<KnockRequestsAction>(KnockRequestsAction.None) }
val permissions by knockRequestsService.permissionsFlow.collectAsState()
val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: KnockRequestsListEvents) {
when (event) {
KnockRequestsListEvents.AcceptAll -> {
currentAction = KnockRequestsAction.AcceptAll
}
is KnockRequestsListEvents.Accept -> {
currentAction = KnockRequestsAction.Accept(event.knockRequest)
}
is KnockRequestsListEvents.Decline -> {
currentAction = KnockRequestsAction.Decline(event.knockRequest)
}
is KnockRequestsListEvents.DeclineAndBan -> {
currentAction = KnockRequestsAction.DeclineAndBan(event.knockRequest)
}
KnockRequestsListEvents.ResetCurrentAction -> {
asyncAction.value = AsyncAction.Uninitialized
currentAction = KnockRequestsAction.None
}
KnockRequestsListEvents.RetryCurrentAction -> {
coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true)
}
KnockRequestsListEvents.ConfirmCurrentAction -> {
coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true)
}
}
}
LaunchedEffect(currentAction) {
executeAction(currentAction, asyncAction, isActionConfirmed = false)
}
return KnockRequestsListState(
knockRequests = knockRequests,
currentAction = currentAction,
permissions = permissions,
asyncAction = asyncAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.executeAction(
currentAction: KnockRequestsAction,
asyncAction: MutableState<AsyncAction<Unit>>,
isActionConfirmed: Boolean,
) = launch {
when (currentAction) {
is KnockRequestsAction.Accept -> {
runUpdatingState(asyncAction) {
knockRequestsService.acceptKnockRequest(currentAction.knockRequest)
}
}
is KnockRequestsAction.Decline -> {
if (isActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.declineKnockRequest(currentAction.knockRequest)
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
}
is KnockRequestsAction.DeclineAndBan -> {
if (isActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.declineAndBanKnockRequest(currentAction.knockRequest)
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
}
is KnockRequestsAction.AcceptAll -> {
if (isActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.acceptAllKnockRequests()
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
}
KnockRequestsAction.None -> Unit
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsListState(
val knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>>,
val currentAction: KnockRequestsAction,
val asyncAction: AsyncAction<Unit>,
val permissions: KnockRequestPermissions,
val eventSink: (KnockRequestsListEvents) -> Unit,
) {
val canAcceptAll = permissions.canAccept && knockRequests is AsyncData.Success && knockRequests.data.size > 1
}
@Immutable
sealed interface KnockRequestsAction {
data object None : KnockRequestsAction
data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data object AcceptAll : KnockRequestsAction
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockRequestsListState> {
override val values: Sequence<KnockRequestsListState>
get() = sequenceOf(
aKnockRequestsListState(
knockRequests = AsyncData.Loading(),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf()
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," +
"very amazing reason, I know, right, I'm so good at writing reasons."
)
)
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable(),
aKnockRequestPresentable(
userId = UserId("@user:example.com"),
displayName = null,
avatarUrl = null,
reason = null,
)
)
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.ConfirmingNoParams,
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Loading,
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
permissions = KnockRequestPermissions(
canAccept = false,
canDecline = true,
canBan = true,
),
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
permissions = KnockRequestPermissions(
canAccept = true,
canDecline = false,
canBan = true,
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
permissions = KnockRequestPermissions(
canAccept = false,
canDecline = false,
canBan = true,
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
permissions = KnockRequestPermissions(
canAccept = true,
canDecline = true,
canBan = false,
),
),
)
}
fun aKnockRequestsListState(
knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsAction = KnockRequestsAction.None,
asyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
permissions: KnockRequestPermissions = KnockRequestPermissions(
canAccept = true,
canDecline = true,
canBan = true,
),
eventSink: (KnockRequestsListEvents) -> Unit = {},
) = KnockRequestsListState(
knockRequests = knockRequests,
currentAction = currentAction,
asyncAction = asyncAction,
permissions = permissions,
eventSink = eventSink,
)

View File

@@ -0,0 +1,498 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun KnockRequestsListView(
state: KnockRequestsListState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
KnockRequestsListTopBar(onBackClick = onBackClick)
},
content = { padding ->
KnockRequestsListContent(
state = state,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
)
}
)
}
@Composable
private fun KnockRequestsListContent(
state: KnockRequestsListState,
modifier: Modifier = Modifier,
) {
fun onAcceptClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Accept(knockRequest))
}
fun onDeclineClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Decline(knockRequest))
}
fun onBanClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest))
}
var bottomPaddingInPixels by remember { mutableIntStateOf(0) }
Box(modifier.fillMaxSize()) {
when (state.knockRequests) {
is AsyncData.Success -> {
val knockRequests = state.knockRequests.data
if (knockRequests.isEmpty()) {
KnockRequestsEmptyList()
} else {
KnockRequestsList(
knockRequests = knockRequests,
canAccept = state.permissions.canAccept,
canDecline = state.permissions.canDecline,
canBan = state.permissions.canBan,
onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick,
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()),
)
}
}
is AsyncData.Loading -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(16.dp),
modifier = Modifier.align(Alignment.Center)
) {
CircularProgressIndicator(color = ElementTheme.colors.iconPrimary)
Text(
text = stringResource(R.string.screen_knock_requests_list_initial_loading_title),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
else -> Unit
}
KnockRequestsActionsView(
currentAction = state.currentAction,
asyncAction = state.asyncAction,
onConfirm = {
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
},
onRetry = {
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
},
onDismiss = {
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
},
)
if (state.canAcceptAll) {
KnockRequestsAcceptAll(
onClick = {
state.eventSink(KnockRequestsListEvents.AcceptAll)
},
onHeightChange = { height ->
bottomPaddingInPixels = height
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
@Composable
private fun KnockRequestsActionsView(
currentAction: KnockRequestsAction,
asyncAction: AsyncAction<Unit>,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
AsyncActionView(
async = asyncAction,
onSuccess = { onDismiss() },
onErrorDismiss = onDismiss,
confirmationDialog = {
KnockRequestActionConfirmation(
currentAction = currentAction,
onSubmit = onConfirm,
onDismiss = onDismiss,
)
},
progressDialog = {
KnockRequestActionProgress(target = currentAction)
},
errorMessage = {
when (currentAction) {
is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_failed_alert_description)
is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description)
is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description)
KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_failed_alert_description)
else -> ""
}
},
onRetry = onRetry,
)
}
}
@Composable
private fun KnockRequestActionConfirmation(
currentAction: KnockRequestsAction,
onSubmit: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val (title, content, submitText) = when (currentAction) {
KnockRequestsAction.AcceptAll -> Triple(
stringResource(R.string.screen_knock_requests_list_accept_all_alert_title),
stringResource(R.string.screen_knock_requests_list_accept_all_alert_description),
stringResource(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title),
)
is KnockRequestsAction.Decline -> Triple(
stringResource(R.string.screen_knock_requests_list_decline_alert_title),
stringResource(R.string.screen_knock_requests_list_decline_alert_description, currentAction.knockRequest.getBestName()),
stringResource(R.string.screen_knock_requests_list_decline_alert_confirm_button_title),
)
is KnockRequestsAction.DeclineAndBan -> Triple(
stringResource(R.string.screen_knock_requests_list_ban_alert_title),
stringResource(R.string.screen_knock_requests_list_ban_alert_description, currentAction.knockRequest.getBestName()),
stringResource(R.string.screen_knock_requests_list_ban_alert_confirm_button_title),
)
else -> return
}
ConfirmationDialog(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmit,
onDismiss = onDismiss,
modifier = modifier,
)
}
@Composable
private fun KnockRequestActionProgress(
target: KnockRequestsAction,
modifier: Modifier = Modifier,
) {
val progressText = when (target) {
is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_loading_title)
is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_loading_title)
is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_ban_loading_title)
KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_loading_title)
else -> return
}
ProgressDialog(
text = progressText,
modifier = modifier,
)
}
@Composable
private fun KnockRequestsList(
knockRequests: ImmutableList<KnockRequestPresentable>,
canAccept: Boolean,
canDecline: Boolean,
canBan: Boolean,
onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = contentPadding,
) {
itemsIndexed(knockRequests) { index, knockRequest ->
KnockRequestItem(
knockRequest = knockRequest,
onAcceptClick = onAcceptClick,
canBan = canBan,
canDecline = canDecline,
canAccept = canAccept,
onDeclineClick = onDeclineClick,
onBanClick = onBanClick,
)
if (index != knockRequests.size - 1) {
HorizontalDivider()
}
}
}
}
@Composable
private fun KnockRequestItem(
knockRequest: KnockRequestPresentable,
canAccept: Boolean,
canDecline: Boolean,
canBan: Boolean,
onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Avatar(knockRequest.getAvatarData(AvatarSize.KnockRequestItem))
Spacer(modifier = Modifier.width(16.dp))
Column {
// Name and date
Row {
Text(
modifier = Modifier
.clipToBounds()
.weight(1f),
text = knockRequest.getBestName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
style = ElementTheme.typography.fontBodyLgMedium,
)
val formattedDate = knockRequest.formattedDate
if (!formattedDate.isNullOrEmpty()) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formattedDate,
color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
// UserId
if (!knockRequest.displayName.isNullOrEmpty()) {
Text(
text = knockRequest.userId.value,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
// Reason
val reason = knockRequest.reason
if (!reason.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.animateContentSize()
.clickable(enabled = isExpandable) { isExpanded = !isExpanded }
) {
Text(
text = reason,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = if (isExpanded) Int.MAX_VALUE else 3,
onTextLayout = { result ->
if (!isExpanded && result.hasVisualOverflow) {
isExpandable = true
}
},
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Box(modifier = Modifier.size(24.dp)) {
if (isExpandable) {
Icon(
imageVector = if (isExpanded) CompoundIcons.ChevronUp() else CompoundIcons.ChevronDown(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
}
}
}
// Actions
if (canDecline || canAccept) {
Spacer(modifier = Modifier.height(12.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
if (canDecline) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = {
onDeclineClick(knockRequest)
},
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
if (canAccept) {
Button(
text = stringResource(CommonStrings.action_accept),
onClick = {
onAcceptClick(knockRequest)
},
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}
if (canBan) {
Spacer(modifier = Modifier.height(12.dp))
TextButton(
text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title),
onClick = {
onBanClick(knockRequest)
},
destructive = true,
size = ButtonSize.Small,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Composable
private fun KnockRequestsAcceptAll(
onClick: () -> Unit,
onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.shadow(elevation = 24.dp, spotColor = Color.Transparent)
.background(color = ElementTheme.colors.bgCanvasDefault)
.padding(vertical = 12.dp, horizontal = 16.dp)
.onSizeChanged { onHeightChange(it.height) }
) {
OutlinedButton(
text = stringResource(R.string.screen_knock_requests_list_accept_all_button_title),
onClick = onClick,
size = ButtonSize.Medium,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun KnockRequestsEmptyList(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.padding(
horizontal = 32.dp,
vertical = 48.dp,
),
contentAlignment = Alignment.Center,
) {
IconTitleSubtitleMolecule(
title = stringResource(R.string.screen_knock_requests_list_empty_state_title),
subTitle = stringResource(R.string.screen_knock_requests_list_empty_state_description),
iconStyle = BigIcon.Style.Default(CompoundIcons.AskToJoin()),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun KnockRequestsListTopBar(onBackClick: () -> Unit) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.screen_knock_requests_list_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
@PreviewsDayNight
@Composable
internal fun KnockRequestsListViewPreview(
@PreviewParameter(KnockRequestsListStateProvider::class) state: KnockRequestsListState
) = ElementPreview {
KnockRequestsListView(
state = state,
onBackClick = {},
)
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Ano, přijmout všechny"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Opravdu chcete přijmout všechny žádosti o vstup?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Přijmout všechny požadavky"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Přijmout vše"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nemohli jsme přijmout všechny žádosti. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nepodařilo se přijmout všechny žádosti"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Přijímání všech žádostí o vstup"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Tuto žádost jsme nemohli přijmout. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Žádost se nepodařilo přijmout"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Přijímání žádosti o vstup"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ano, odmítnout a vykázat"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Opravdu chcete odmítnout a vykázat %1$s? Tento uživatel nebude moci znovu požádat o vstup do této místnosti."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Odmítnout a zakázat vstup"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Odmítání vstupu a vykázání"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ano, odmítnout"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Opravdu chcete odmítnout %1$s žádost o vstup do této místnosti?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Odmítnout vstup"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Odmítnout a vykázat"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Tuto žádost jsme nemohli odmítnout. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Žádost se nepodařilo odmítnout"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Odmítání žádosti o vstup"</string>
<string name="screen_knock_requests_list_empty_state_description">"Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde."</string>
<string name="screen_knock_requests_list_empty_state_title">"Žádná čekající žádost o vstup"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Načítání žádostí o vstup…"</string>
<string name="screen_knock_requests_list_title">"Žádosti o vstup"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d další chce vstoupit do této místnosti"</item>
<item quantity="few">"%1$s +%2$d další chtějí vstoupit do této místnosti"</item>
<item quantity="other">"%1$s +%2$d dalších chce vstoupit do této místnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobrazit vše"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Přijmout"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstoupit do této místnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobrazit"</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Ja, akzeptiere alle"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Sind Sie sicher, dass Sie alle Beitrittsanfragen akzeptieren möchten?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Akzeptiere alle Anfragen"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Alle akzeptieren"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ja, ablehnen und sperren"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Sind Sie sicher, dass Sie %1$s ablehnen und sperren möchten ? Dieser Benutzer kann keinen erneuten Zugriff auf diesen Raum anfordern."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Ablehnen und Zugriff verbieten"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ja, ablehnen"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Sind Sie sicher, dass Sie die %1$s Anfrage, diesem Chatroom beizutreten, ablehnen möchten ?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Zugriff verweigern"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Ablehnen und sperren"</string>
<string name="screen_knock_requests_list_empty_state_description">"Falls jemand um Aufnahme in den Raum bittet, können Sie dessen Anfrage hier sehen."</string>
<string name="screen_knock_requests_list_empty_state_title">"Keine ausstehende Beitrittsanfrage"</string>
<string name="screen_knock_requests_list_title">"Beitrittsanfragen"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
<item quantity="other">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Alles ansehen"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Akzeptieren"</string>
<string name="screen_room_single_knock_request_title">"%1$s möchte diesem Chatroom beitreten"</string>
<string name="screen_room_single_knock_request_view_button_title">"Ansicht"</string>
</resources>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Ναι, αποδοχή όλων"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Απόρριψη και αποκλεισμός πρόσβασης"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Γίνεται απόρριψη και αποκλεισμός πρόσβασης"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ναι, απόρριψη"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Σίγουρα θες να απορρίψεις το αίτημα του χρήστη %1$s να συμμετάσχει στο δωμάτιο;"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Απόρριψη πρόσβασης"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Απόρριψη και αποκλεισμός"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
<item quantity="other">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Προβολή όλων"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Αποδοχή"</string>
<string name="screen_room_single_knock_request_title">"Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο"</string>
<string name="screen_room_single_knock_request_view_button_title">"Προβολή"</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Jah, võta kõik vastu"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Kas sa oled kindel, et soovid kõik vastu liitumist soovinud võtta?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Võta kõik vastu"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Nõustu kõigiga"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Jah, keeldu liitumisest ning keela ligipääs"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Kas sa oled kindel, et soovid kasutajale %1$s keelata ligipääsu siia jututuppa ning seada talle suhtluskeelu? Seetõttu ta ei saa ka enam hiljem liitumispalvet saata."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Keeldu liitumisest ja keela ligipääs"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Jah, keeldu"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Kas sa oled kindel, et soovid kasutajale %1$s keelata ligipääsu siia jututuppa?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Keela ligipääs"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Keeldu ja määra suhtluskeeld"</string>
<string name="screen_knock_requests_list_empty_state_description">"Kui keegi soovib jututoaga liituda, siis need päringud on kuvatud siin."</string>
<string name="screen_knock_requests_list_empty_state_title">"Pole ühtegi liitumispalvet"</string>
<string name="screen_knock_requests_list_title">"Liitumispalved"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda"</item>
<item quantity="other">"%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Vaata kõiki"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Nõustu"</string>
<string name="screen_room_single_knock_request_title">"%1$s soovib selle jututoaga liituda"</string>
<string name="screen_room_single_knock_request_view_button_title">"Vaata"</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Kyllä, hyväksy kaikki"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Haluatko varmasti hyväksyä kaikki liittymispyynnöt?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Hyväksy kaikki pyynnöt"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Hyväksy kaikki"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Kyllä, hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä huoneeseen ja antaa hänelle porttikiellon? Hän ei voi enää pyytää lupaa liittyä tähän huoneeseen."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Kyllä, hylkää"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä tähän huoneeseen?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Hylkää pyyntö"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_empty_state_description">"Kun joku pyytää liittyä huoneeseen, näet hänen pyyntönsä täällä."</string>
<string name="screen_knock_requests_list_empty_state_title">"Ei odottavia liittymispyyntöjä"</string>
<string name="screen_knock_requests_list_title">"Liittymispyynnöt"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d muu haluavat liittyä tähän huoneeseen"</item>
<item quantity="other">"%1$s +%2$d muuta haluavat liittyä tähän huoneeseen"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Näytä kaikki"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Hyväksy"</string>
<string name="screen_room_single_knock_request_title">"%1$s haluaa liittyä tähän huoneeseen"</string>
<string name="screen_room_single_knock_request_view_button_title">"Näytä"</string>
</resources>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Oui, tout accepter"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Êtes-vous sûr de vouloir accepter toutes les demandes pour rejoindre le salon ?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Tout accepter"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Tout accepter"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Toutes les demandes nont pas pu être acceptées. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Toutes les demandes nont pas été acceptées"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Accepter toutes les demandes à rejoindre"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"La demande na pas pu être acceptée. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Impossible daccepter la demande"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Accepter la demande à rejoindre"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Oui, rejeter et bannir"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Êtes-vous sûr de vouloir rejeter la demande et bannir %1$s ? Cet utilisateur ne pourra pas demander à nouveau à rejoindre ce salon."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Refuser et interdire laccès"</string>
<string name="screen_knock_requests_list_ban_loading_title">"En cours de traitement…"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Oui, refuser"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Êtes-vous sûr de vouloir refuser la demande de %1$s à rejoindre le salon ?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Refuser laccès"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Refuser et bannir"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nous navons pas pu refuser cette demande. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Echec"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Traitement en cours…"</string>
<string name="screen_knock_requests_list_empty_state_description">"Lorsque quelquun demandera à rejoindre le salon, vous pourrez voir sa demande ici."</string>
<string name="screen_knock_requests_list_empty_state_title">"Personne ne demande à rejoindre le salon"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Chargement…"</string>
<string name="screen_knock_requests_list_title">"Demandes en attente"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s et %2$d autre personne souhaitent rejoindre ce salon"</item>
<item quantity="other">"%1$s et %2$d autres personnes souhaitent rejoindre ce salon"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Tout afficher"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accepter"</string>
<string name="screen_room_single_knock_request_title">"%1$s souhaite rejoindre ce salon"</string>
<string name="screen_room_single_knock_request_view_button_title">"Voir"</string>
</resources>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Igen, az összes elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Biztos, hogy elfogadja az összes csatlakozási kérelmet?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Minden kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Összes elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nem sikerült az összes kérés fogadása. Újra megpróbálja?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nem sikerült az összes kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Összes csatlakozási kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Nem sikerült elfogadni a kérést. Megpróbálja újra?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Nem sikerült elfogadni a kérést"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Csatlakozási kérés elfogadása"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Igen, elutasítás és kitiltás"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Biztos, hogy elutasítja %1$s kérését és ki is tiltja? Többé nem fogja tudni azt kérni, hogy csatlakozhasson ehhez a szobához."</string>
<string name="screen_knock_requests_list_ban_alert_title">"A hozzáférés elutasítása és kitiltás"</string>
<string name="screen_knock_requests_list_ban_loading_title">"A hozzáférés megtagadása és kitiltás"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Igen, elutasítás"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Biztos, hogy elutasítja %1$s kérését, hogy csatlakozzon a szobához?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Hozzáférés elutasítása"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Elutasítás és kitiltás"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nem sikerült elutasítani a kérést. Megpróbálja újra?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Nem sikerült elutasítani a kérést"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Csatlakozási kérés elutasítása"</string>
<string name="screen_knock_requests_list_empty_state_description">"Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését."</string>
<string name="screen_knock_requests_list_empty_state_title">"Nincs függőben lévő csatlakozási kérelem"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Csatlakozási kérések betöltése…"</string>
<string name="screen_knock_requests_list_title">"Csatlakozási kérelmek"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
<item quantity="other">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Összes megtekintése"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Elfogadás"</string>
<string name="screen_room_single_knock_request_title">"%1$s szeretne csatlakozni ehhez a szobához"</string>
<string name="screen_room_single_knock_request_view_button_title">"Megtekintés"</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Sì, accetta tutte"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Sei sicuro di voler accettare tutte le richieste di accesso?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Accetta tutte le richieste"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Accetta tutte"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Sì, rifiuta e blocca"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Sei sicuro di voler rifiutare e bloccare %1$s? Questo utente non potrà richiedere nuovamente l\'accesso per entrare in questa stanza."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Rifiuta e blocca l\'accesso"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Sì, rifiuta"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Sei sicuro di voler rifiutare la richiesta di %1$s ad entrare in a questa stanza?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Rifiuta l\'accesso"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Rifiuta e blocca"</string>
<string name="screen_knock_requests_list_empty_state_description">"Quando qualcuno ti chiederà di entrare nella stanza, potrai vedere la sua richiesta qui."</string>
<string name="screen_knock_requests_list_empty_state_title">"Nessuna richiesta di accesso in sospeso"</string>
<string name="screen_knock_requests_list_title">"Richieste di accesso"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d vogliono entrare in questa stanza"</item>
<item quantity="other">"%1$s +%2$d vogliono entrare in questa stanza"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Visualizza tutte"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accetta"</string>
<string name="screen_room_single_knock_request_title">"%1$s vuole entrare in questa stanza"</string>
<string name="screen_room_single_knock_request_view_button_title">"Visualizza"</string>
</resources>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Да, принять все"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Вы действительно хотите принять все заявки на присоединение?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Принять все запросы"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Принять всё"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Да, отклонить и запретить"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Вы уверен, что хочешь отклонить и запретить %1$s? Этот пользователь больше не сможет запросить доступ к этой комнате."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Отклонить и запретить доступ"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Да, отклонить"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Вы уверены, что хотите отклонить %1$s запрос на присоединение к этой комнате?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Отклонить доступ"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Отклонить и запретить"</string>
<string name="screen_knock_requests_list_empty_state_description">"Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате."</string>
<string name="screen_knock_requests_list_empty_state_title">"Нет ожидающих запросов на присоединение"</string>
<string name="screen_knock_requests_list_title">"Запросы на присоединение"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d хочет присоединиться к этой комнате"</item>
<item quantity="few">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
<item quantity="many">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Показать все"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Принять"</string>
<string name="screen_room_single_knock_request_title">"%1$s хочет присоединиться к этой комнате"</string>
<string name="screen_room_single_knock_request_view_button_title">"Просмотр"</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_button_title">"Prijať všetky"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Odmietnuť a zakázať"</string>
<string name="screen_knock_requests_list_empty_state_description">"Keď niekto požiada, aby sa pripojil k miestnosti, jeho žiadosť si môžete pozrieť tu."</string>
<string name="screen_knock_requests_list_empty_state_title">"Žiadna čakajúca žiadosť o pripojenie"</string>
<string name="screen_knock_requests_list_title">"Žiadosti o pripojenie"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="few">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="other">"%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobraziť všetko"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Prijať"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstúpiť do tejto miestnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobraziť"</string>
</resources>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Yes, accept all"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Are you sure you want to accept all requests to join?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Accept all requests"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Accept all"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"We couldnt accept all requests. Would you like to try again?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Failed to accept all requests"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Accepting all requests to join"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"We couldnt accept this request. Would you like to try again?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Failed to accept request"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Accepting request to join"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Yes, decline and ban"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Are you sure you want to decline and ban %1$s? This user wont be able to request access to join this room again."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Decline and ban from accessing"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Declining and banning access"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Yes, decline"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Are you sure you want to decline %1$s request to join this room?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Decline access"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Decline and ban"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"We couldnt decline this request. Would you like to try again?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Failed to decline request"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Declining request to join"</string>
<string name="screen_knock_requests_list_empty_state_description">"When somebody will ask to join the room, youll be able to see their request here."</string>
<string name="screen_knock_requests_list_empty_state_title">"No pending request to join"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Loading requests to join…"</string>
<string name="screen_knock_requests_list_title">"Requests to join"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d other want to join this room"</item>
<item quantity="other">"%1$s +%2$d others want to join this room"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"View all"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accept"</string>
<string name="screen_room_single_knock_request_title">"%1$s wants to join this room"</string>
<string name="screen_room_single_knock_request_view_button_title">"View"</string>
</resources>

View File

@@ -0,0 +1,243 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
@Test
fun `present - when feature is disabled then the banner should be hidden`() = runTest {
val knockRequests = flowOf(listOf(FakeKnockRequest()))
val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
}
}
}
@Test
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
val knockRequests = flowOf(emptyList<KnockRequest>())
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
}
}
}
@Test
fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest {
val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false)
presenter.test {
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
}
}
}
@Test
fun `present - when everything is setup to manage knocks with data, then the banner should be visible`() = runTest {
val knockRequests = flowOf(
listOf(
FakeKnockRequest(
reason = "A reason",
)
)
)
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.isVisible).isTrue()
assertThat(state.knockRequests).hasSize(1)
assertThat(state.canAccept).isTrue()
assertThat(state.reason).isEqualTo("A reason")
}
}
}
@Test
fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest {
val knockRequests = flowOf(
listOf(
FakeKnockRequest(
displayName = "Alice",
),
FakeKnockRequest(
displayName = "Bob",
),
FakeKnockRequest(
displayName = "Charlie",
),
)
)
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.isVisible).isTrue()
assertThat(state.knockRequests).hasSize(3)
assertThat(state.reason).isNull()
assertThat(state.subtitle).isNull()
}
}
}
@Test
fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest {
val knockRequests = flowOf(
listOf(
FakeKnockRequest(
displayName = "Alice",
isSeen = true,
userId = A_USER_ID
),
FakeKnockRequest(
displayName = "Bob",
isSeen = true,
userId = A_USER_ID_2
),
FakeKnockRequest(
isSeen = false,
displayName = "Charlie",
reason = "A reason",
userId = A_USER_ID_3
),
)
)
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.isVisible).isTrue()
// Only Charlie should be displayed
assertThat(state.knockRequests).hasSize(1)
assertThat(state.reason).isEqualTo("A reason")
assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value)
}
}
}
@Test
fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest {
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
val knockRequest = FakeKnockRequest(
displayName = "Alice",
reason = "A reason",
acceptLambda = acceptLambda
)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
}
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
assertThat(state.displayAcceptError).isFalse()
}
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
assertThat(state.displayAcceptError).isTrue()
}
awaitItem().also { state ->
assertThat(state.isVisible).isTrue()
assertThat(state.displayAcceptError).isTrue()
}
awaitItem().also { state ->
assertThat(state.isVisible).isTrue()
assertThat(state.displayAcceptError).isFalse()
}
assert(acceptLambda).isCalledOnce()
}
}
@Test
fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest {
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val knockRequest = FakeKnockRequest(
displayName = "Alice",
reason = "A reason",
acceptLambda = acceptLambda
)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.knockRequests).hasSize(1)
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
}
awaitItem().also { state ->
assertThat(state.isVisible).isFalse()
}
advanceUntilIdle()
assert(acceptLambda).isCalledOnce()
}
}
@Test
fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest {
val markAsSeenLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val knockRequests = flowOf(
listOf(
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
FakeKnockRequest(markAsSeenLambda = markAsSeenLambda),
)
)
val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests)
presenter.test {
skipItems(2)
awaitItem().also { state ->
state.eventSink(KnockRequestsBannerEvents.Dismiss)
}
advanceUntilIdle()
assert(markAsSeenLambda).isCalledExactly(3)
}
}
}
private fun TestScope.createKnockRequestsBannerPresenter(
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()),
canAcceptKnockRequests: Boolean = true,
isFeatureEnabled: Boolean = true,
): KnockRequestsBannerPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled),
permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)),
)
return KnockRequestsBannerPresenter(
knockRequestsService = knockRequestsService,
appCoroutineScope = this,
)
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.banner
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnockRequestsBannerViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on view on single request invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
ensureCalledOnce {
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
onViewRequestsClick = it
)
rule.clickOn(R.string.screen_room_single_knock_request_view_button_title)
}
}
@Test
fun `clicking on view all when multiple requests invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
ensureCalledOnce {
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(displayName = "Alice"),
aKnockRequestPresentable(displayName = "Bob"),
aKnockRequestPresentable(displayName = "Charlie")
),
eventSink = eventsRecorder,
),
onViewRequestsClick = it
)
rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title)
}
}
@Test
fun `clicking on accept on a single request emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest)
}
@Test
fun `clicking on dismiss emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsBannerView(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsBannerView(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}

View File

@@ -0,0 +1,304 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.knockrequests.impl.list
import com.google.common.truth.Truth.assertThat
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class KnockRequestsListPresenterTest {
@Test
fun `present - initial states should be emitted`() = runTest {
val presenter = createKnockRequestsListPresenter()
presenter.test {
awaitItem().also { state ->
assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java)
assertThat(state.permissions.canAccept).isFalse()
assertThat(state.permissions.canDecline).isFalse()
assertThat(state.permissions.canBan).isFalse()
}
awaitItem().also { state ->
assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java)
assertThat(state.permissions.canAccept).isTrue()
assertThat(state.permissions.canDecline).isTrue()
assertThat(state.permissions.canBan).isTrue()
}
awaitItem().also { state ->
assertThat(state.knockRequests).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.knockRequests.dataOrNull()).isEmpty()
}
}
}
@Test
fun `present - accept success scenario`() = runTest {
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable))
}
skipItems(1)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable))
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
}
assert(acceptLambda).isCalledOnce()
}
}
@Test
fun `present - accept failure scenario`() = runTest {
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable))
}
skipItems(1)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable))
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull()).hasSize(1)
}
assert(acceptLambda).isCalledExactly(2)
}
}
@Test
fun `present - decline success scenario`() = runTest {
val declineLambda = lambdaRecorder<String?, Result<Unit>> { Result.success(Unit) }
val knockRequest = FakeKnockRequest(declineLambda = declineLambda)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
state.eventSink(KnockRequestsListEvents.Decline(knockRequestPresentable))
}
skipItems(1)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Decline(knockRequestPresentable))
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
}
}
assert(declineLambda).isCalledOnce()
}
@Test
fun `present - decline and ban success scenario`() = runTest {
val declineAndBanLambda = lambdaRecorder<String?, Result<Unit>> { Result.success(Unit) }
val knockRequest = FakeKnockRequest(declineAndBanLambda = declineAndBanLambda)
val knockRequests = flowOf(listOf(knockRequest))
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequestPresentable))
}
skipItems(1)
awaitItem().also { state ->
val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!!
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.DeclineAndBan(knockRequestPresentable))
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
}
}
assert(declineAndBanLambda).isCalledOnce()
}
@Test
fun `present - accept all success scenario`() = runTest {
val acceptLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val knockRequests = flowOf(
listOf(
FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptLambda),
FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptLambda),
)
)
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.canAcceptAll).isTrue()
state.eventSink(KnockRequestsListEvents.AcceptAll)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll)
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty()
}
}
assert(acceptLambda).isCalledExactly(2)
}
@Test
fun `present - accept all partial success scenario`() = runTest {
val acceptSuccessLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val acceptFailureLambda = lambdaRecorder<Result<Unit>> { Result.failure(Exception()) }
val knockRequests = flowOf(
listOf(
FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptSuccessLambda),
FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptFailureLambda),
)
)
val presenter = createKnockRequestsListPresenter(
knockRequestsFlow = knockRequests
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.canAcceptAll).isTrue()
state.eventSink(KnockRequestsListEvents.AcceptAll)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll)
assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java)
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None)
assertThat(state.knockRequests.dataOrNull()).hasSize(1)
}
}
assert(acceptFailureLambda).isCalledOnce()
assert(acceptSuccessLambda).isCalledOnce()
}
private fun TestScope.createKnockRequestsListPresenter(
canAccept: Boolean = true,
canDecline: Boolean = true,
canBan: Boolean = true,
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList())
): KnockRequestsListPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
isKnockFeatureEnabledFlow = flowOf(true),
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
)
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnockRequestsListViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
ensureCalledOnce {
rule.setKnockRequestsListView(
aKnockRequestsListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on accept emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest))
}
@Test
fun `clicking on decline emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest))
}
@Test
fun `clicking on decline and ban emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest))
}
@Test
fun `clicking on accept all emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll)
}
@Test
fun `retry on async view retry emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction)
}
@Test
fun `canceling async view emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction)
}
@Test
fun `confirming async view emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.ConfirmingNoParams,
currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsListView(
state: KnockRequestsListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsListView(
state = state,
onBackClick = onBackClick,
)
}
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Êtes-vous sûr de vouloir quitter cette discussion? Vous ne pourrez pas la rejoindre à nouveau sans y être invité."</string>
<string name="leave_conversation_alert_subtitle">"Êtes-vous sûr de vouloir quitter cette discussion ? Vous ne pourrez pas la rejoindre à nouveau sans y être invité."</string>
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à lavenir, y compris vous."</string>
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon nest pas public et vous ne pourrez pas le rejoindre sans invitation."</string>
<string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string>

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
sealed interface DependencyLicensesListEvent {
data class SetFilter(val filter: String) : DependencyLicensesListEvent
}

View File

@@ -29,6 +29,10 @@ class DependencyLicensesListPresenter @Inject constructor(
var licenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filteredLicenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filter by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
runCatching {
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
@@ -36,6 +40,32 @@ class DependencyLicensesListPresenter @Inject constructor(
licenses = AsyncData.Failure(it)
}
}
return DependencyLicensesListState(licenses = licenses)
LaunchedEffect(filter, licenses.dataOrNull()) {
val data = licenses.dataOrNull()
val safeFilter = filter.trim()
if (data != null && safeFilter.isNotEmpty()) {
filteredLicenses = AsyncData.Success(data.filter {
it.safeName.contains(safeFilter, ignoreCase = true) ||
it.groupId.contains(safeFilter, ignoreCase = true) ||
it.artifactId.contains(safeFilter, ignoreCase = true)
}.toPersistentList())
} else {
filteredLicenses = licenses
}
}
fun handleEvent(dependencyLicensesListEvent: DependencyLicensesListEvent) {
when (dependencyLicensesListEvent) {
is DependencyLicensesListEvent.SetFilter -> {
filter = dependencyLicensesListEvent.filter
}
}
}
return DependencyLicensesListState(
licenses = filteredLicenses,
filter = filter,
eventSink = ::handleEvent,
)
}
}

View File

@@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList
data class DependencyLicensesListState(
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
val filter: String,
val eventSink: (DependencyLicensesListEvent) -> Unit,
)

View File

@@ -11,28 +11,49 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.features.licenses.impl.model.License
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class DependencyLicensesListStateProvider : PreviewParameterProvider<DependencyLicensesListState> {
override val values: Sequence<DependencyLicensesListState>
get() = sequenceOf(
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Loading()
),
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Failure(Exception("Failed to load licenses"))
),
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
)
)
),
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
),
filter = "a filter",
),
)
}
private fun aDependencyLicensesListState(
licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
filter: String = "",
): DependencyLicensesListState {
return DependencyLicensesListState(
licenses = licenses,
filter = filter,
eventSink = {},
)
}
internal fun aDependencyLicenseItem(
name: String? = "A dependency",
) = DependencyLicenseItem(

View File

@@ -7,31 +7,36 @@
package io.element.android.features.licenses.impl.list
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
@@ -48,48 +53,64 @@ fun DependencyLicensesListView(
)
},
) { contentPadding ->
LazyColumn(
Column(
modifier = Modifier
.padding(contentPadding)
.padding(horizontal = 16.dp)
) {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
if (state.licenses.isSuccess()) {
// Search field
OutlinedTextField(
value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = null,
)
},
modifier = Modifier.fillMaxWidth(),
)
}
LazyColumn {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
},
onClick = {
onOpenLicense(license)
}
)
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
)
},
onClick = {
onOpenLicense(license)
}
)
}
}
}
}

View File

@@ -33,6 +33,7 @@ class DependencyLicensesListPresenterTest {
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()).isEmpty()
assertThat(finalState.filter).isEqualTo("")
}
}
@@ -54,6 +55,40 @@ class DependencyLicensesListPresenterTest {
}
}
@Test
fun `present - initial state, one license, set filter`() = runTest {
val anItem = aDependencyLicenseItem()
val presenter = createPresenter {
listOf(anItem)
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val loadedState = awaitItem()
assertThat(loadedState.licenses.isSuccess()).isTrue()
assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1)
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep"))
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("dep")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh"))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0)
assertThat(state.filter).isEqualTo("bleh")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter(""))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("")
}
}
}
private fun createPresenter(
provideResult: () -> List<DependencyLicenseItem>
) = DependencyLicensesListPresenter(

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"Biometrické ověřování"</string>
<string name="screen_app_lock_biometric_unlock">"biometrické odemknutí"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Odemkněte pomocí biometrie"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Potvrďte biometrické údaje"</string>
<string name="screen_app_lock_forgot_pin">"Zapomněli jste PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Změnit PIN kód"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povolit biometrické odemykání"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrinen tunnistus"</string>
<string name="screen_app_lock_biometric_unlock">"biometrinen tunnistus"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Avaa biometrisellä"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Vahvista biometrinen tunniste"</string>
<string name="screen_app_lock_forgot_pin">"Unohtuiko PIN-koodi?"</string>
<string name="screen_app_lock_settings_change_pin">"Vaihda PIN-koodi"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Salli biometrinen tunnistus"</string>

View File

@@ -4,12 +4,12 @@
<string name="screen_app_lock_biometric_unlock">"déverrouillage biométrique"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirmer la biométrie"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié ?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>
<string name="screen_app_lock_settings_remove_pin">"Supprimer le code PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Êtes-vous certain de vouloir supprimer le code PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Supprimer le code PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Êtes-vous certain de vouloir supprimer le code PIN ?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Supprimer le code PIN ?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Autoriser %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Je préfère utiliser le code PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Gagnez du temps en utilisant %1$s pour déverrouiller lapplication à chaque fois."</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrikus hitelesítés"</string>
<string name="screen_app_lock_biometric_unlock">"biometrikus feloldás"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Feloldás biometrikus adatokkal"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Biometrikus megerősítés"</string>
<string name="screen_app_lock_forgot_pin">"Elfelejtette a PIN-kódot?"</string>
<string name="screen_app_lock_settings_change_pin">"PIN-kód módosítása"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Biometrikus feloldás engedélyezése"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"autenticazione biometrica"</string>
<string name="screen_app_lock_biometric_unlock">"sblocco con biometria"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Sblocca con biometria"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Conferma la biometria"</string>
<string name="screen_app_lock_forgot_pin">"PIN dimenticato?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifica il codice PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Consenti lo sblocco biometrico"</string>

View File

@@ -60,6 +60,7 @@ Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo.
<string name="screen_qr_code_login_initial_state_item_3">"Seleziona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Collega un nuovo dispositivo\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scansiona il codice QR con questo dispositivo"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponibile solo se il provider del tuo account lo supporta."</string>
<string name="screen_qr_code_login_initial_state_title">"Apri %1$s su un altro dispositivo per ottenere il codice QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Usa il codice QR mostrato sull\'altro dispositivo."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Riprova"</string>

View File

@@ -14,5 +14,5 @@
<string name="screen_signout_recovery_disabled_subtitle">"Vous êtes sur le point de vous déconnecter de votre dernier appareil. Si vous le faites maintenant, vous perdrez laccès à lhistorique de vos messages."</string>
<string name="screen_signout_recovery_disabled_title">"La récupération nest pas configurée."</string>
<string name="screen_signout_save_recovery_key_subtitle">"Vous êtes sur le point de vous déconnecter de votre dernière session. Si vous le faites maintenant, vous perdrez laccès à lhistorique de vos discussions chiffrées."</string>
<string name="screen_signout_save_recovery_key_title">"Avez-vous sauvegardé votre clé de récupération?"</string>
<string name="screen_signout_save_recovery_key_title">"Avez-vous sauvegardé votre clé de récupération ?"</string>
</resources>

View File

@@ -47,6 +47,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.uiUtils)
@@ -65,6 +66,7 @@ dependencies {
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
implementation(projects.features.knockrequests.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -26,6 +26,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
@@ -46,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@@ -54,6 +56,8 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@@ -95,6 +99,8 @@ class MessagesFlowNode @AssistedInject constructor(
private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@@ -115,6 +121,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class MediaViewer(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -146,6 +153,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data object PinnedMessagesList : NavTarget
@Parcelize
data object KnockRequestsList : NavTarget
}
private val callbacks = plugins<MessagesEntryPoint.Callback>()
@@ -226,22 +236,30 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onViewAllPinnedEvents() {
backstack.push(NavTarget.PinnedMessagesList)
}
override fun onViewKnockRequests() {
backstack.push(NavTarget.KnockRequestsList)
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canDownload = true,
canShare = true,
canShowInfo = true,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun onViewInTimeline(eventId: EventId) {
viewInTimeline(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@@ -302,11 +320,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onViewInTimelineClick(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
viewInTimeline(eventId)
}
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
@@ -326,9 +340,20 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.Empty -> {
node(buildContext) {}
}
NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext)
}
}
}
private fun viewInTimeline(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@@ -403,14 +428,25 @@ class MessagesFlowNode @AssistedInject constructor(
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
eventId = event.eventId,
mediaInfo = MediaInfo(
filename = content.filename,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
senderId = event.senderId,
senderName = event.safeSenderName,
dateSent = event.sentTime,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
waveform = (content as? TimelineItemVoiceContent)?.waveform,
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,

View File

@@ -28,6 +28,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.attachments.Attachment
@@ -71,6 +72,7 @@ class MessagesNode @AssistedInject constructor(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(
navigator = this,
@@ -98,6 +100,7 @@ class MessagesNode @AssistedInject constructor(
fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId)
fun onViewAllPinnedEvents()
fun onViewKnockRequests()
}
override fun onBuilt() {
@@ -206,6 +209,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onJoinCallClick(room.roomId) }
}
private fun onViewKnockRequestsClick() {
callbacks.forEach { it.onViewKnockRequests() }
}
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
@@ -231,6 +238,12 @@ class MessagesNode @AssistedInject constructor(
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
knockRequestsBannerView = {
knockRequestsBannerRenderer.View(
modifier = Modifier,
onViewRequestsClick = this::onViewKnockRequestsClick
)
},
modifier = modifier,
)

View File

@@ -274,7 +274,8 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)

View File

@@ -118,6 +118,7 @@ fun MessagesView(
onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit,
) {
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
@@ -195,8 +196,8 @@ fun MessagesView(
MessagesViewContent(
state = state,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
.padding(padding)
.consumeWindowInsets(padding),
onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
@@ -215,6 +216,7 @@ fun MessagesView(
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
)
},
snackbarHost = {
@@ -284,12 +286,13 @@ private fun MessagesViewContent(
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
knockRequestsBannerView: @Composable () -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@@ -372,6 +375,7 @@ private fun MessagesViewContent(
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
knockRequestsBannerView()
}
},
sheetContent = { subcomposing: Boolean ->
@@ -398,13 +402,13 @@ private fun MessagesViewComposerBottomSheetContents(
Column(modifier = Modifier.fillMaxWidth()) {
SuggestionsPickerView(
modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available
}
}),
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available
}
}),
roomId = state.roomId,
roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(),
@@ -452,8 +456,8 @@ private fun MessagesViewTopBar(
title = {
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
@@ -508,9 +512,9 @@ private fun RoomAvatarAndNameRow(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary)
.padding(16.dp),
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -539,5 +543,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
)
}

View File

@@ -37,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -64,6 +66,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
private val featureFlagService: FeatureFlagService,
private val dateFormatter: DateFormatter,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@@ -131,6 +134,11 @@ class DefaultActionListPresenter @AssistedInject constructor(
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
target.value = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = dateFormatter.format(
timelineItem.sentTimeMillis,
DateFormatterMode.Full,
useRelative = true,
),
displayEmojiReactions = displayEmojiReactions,
verifiedUserSendFailure = verifiedUserSendFailure,
actions = actions.toImmutableList()
@@ -170,6 +178,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
add(TimelineItemAction.EditCaption)
add(TimelineItemAction.RemoveCaption)
}
} else if (timelineItem.content is TimelineItemPollContent) {
add(TimelineItemAction.EditPoll)
} else {
add(TimelineItemAction.Edit)
}

View File

@@ -24,6 +24,7 @@ data class ActionListState(
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val sentTimeFull: String,
val displayEmojiReactions: Boolean,
val verifiedUserSendFailure: VerifiedUserSendFailure,
val actions: ImmutableList<TimelineItemAction>,

View File

@@ -37,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
event = aTimelineItemEvent(
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@@ -49,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayNameAmbiguous = true,
timelineItemReactions = reactionsState,
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@@ -62,6 +64,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemVideoContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@@ -75,6 +78,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemFileContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@@ -88,6 +92,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemAudioContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@@ -101,6 +106,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemVoiceContent(caption = null),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@@ -114,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@@ -125,6 +132,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@@ -136,6 +144,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemPollContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(),
@@ -147,6 +156,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
timelineItemReactions = reactionsState,
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@@ -155,6 +165,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(),
@@ -192,6 +203,7 @@ fun aTimelineItemActionList(
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return setOf(
TimelineItemAction.EndPoll,
TimelineItemAction.EditPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,

View File

@@ -185,6 +185,7 @@ private fun ActionListViewContent(
Column {
MessageSummary(
event = target.event,
sentTimeFull = target.sentTimeFull,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
@@ -245,7 +246,11 @@ private fun ActionListViewContent(
@Suppress("MultipleEmitters") // False positive
@Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
private fun MessageSummary(
event: TimelineItem.Event,
sentTimeFull: String,
modifier: Modifier = Modifier,
) {
val content: @Composable () -> Unit
val icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) }
val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = MaterialTheme.colorScheme.secondary)
@@ -300,20 +305,23 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
icon()
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
SenderName(
senderId = event.senderId,
senderProfile = event.senderProfile,
senderNameMode = SenderNameMode.ActionList,
)
Row {
SenderName(
modifier = Modifier.weight(1f),
senderId = event.senderId,
senderProfile = event.senderProfile,
senderNameMode = SenderNameMode.ActionList,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = sentTimeFull,
style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
}
content()
}
Spacer(modifier = Modifier.width(16.dp))
Text(
event.sentTime,
style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
}
}

View File

@@ -11,30 +11,30 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed class TimelineItemAction(
enum class TimelineItemAction(
@StringRes val titleRes: Int,
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object CopyText : TimelineItemAction(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy)
data object CopyCaption : TimelineItemAction(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
data object EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin)
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward),
CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
Redact(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true),
Reply(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply),
ReplyInThread(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply),
Edit(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit),
EditPoll(CommonStrings.action_edit_poll, CompoundDrawables.ic_compound_edit),
EditCaption(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit),
AddCaption(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit),
RemoveCaption(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_close, destructive = true),
ViewSource(CommonStrings.action_view_source, CompoundDrawables.ic_compound_code),
ReportContent(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true),
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
}

View File

@@ -7,21 +7,25 @@
package io.element.android.features.messages.impl.actionlist.model
import androidx.annotation.VisibleForTesting
class TimelineItemActionComparator : Comparator<TimelineItemAction> {
// See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
private val orderedList = listOf(
@VisibleForTesting
val orderedList = listOf(
TimelineItemAction.EndPoll,
TimelineItemAction.ViewInTimeline,
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.EditPoll,
TimelineItemAction.AddCaption,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
TimelineItemAction.CopyText,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,

View File

@@ -40,5 +40,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {}
)
}

View File

@@ -14,9 +14,9 @@ class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProc
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
return buildList {
add(TimelineItemAction.ViewInTimeline)
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.ViewSource }?.let(::add)
}
}
}

View File

@@ -40,9 +40,12 @@ fun TimelineItemEncryptedView(
UtdCause.UnknownDevice -> {
CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block
}
UtdCause.HistoricalMessage -> {
UtdCause.HistoricalMessageAndBackupIsDisabled -> {
CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block
}
UtdCause.HistoricalMessageAndDeviceIsUnverified -> {
CommonStrings.timeline_decryption_failure_historical_event_unverified_device to CompoundDrawables.ic_compound_block
}
UtdCause.WithheldUnverifiedOrInsecureDevice -> {
CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block
}

View File

@@ -29,8 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Composable
fun TimelineItemEventContentView(

View File

@@ -40,9 +40,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -52,6 +49,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@Composable

View File

@@ -8,9 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
/**
* A fake [TimelineItemPresenterFactories] for screenshot tests.

View File

@@ -20,7 +20,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
@@ -32,14 +33,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua
import io.element.android.libraries.matrix.ui.messages.reply.map
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
class TimelineItemEventFactory @AssistedInject constructor(
@Assisted private val config: TimelineItemsFactoryConfig,
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val dateFormatter: DateFormatter,
private val permalinkParser: PermalinkParser,
) {
@AssistedFactory
@@ -57,9 +57,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly,
)
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender),
@@ -78,6 +79,7 @@ class TimelineItemEventFactory @AssistedInject constructor(
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
@@ -106,7 +108,6 @@ class TimelineItemEventFactory @AssistedInject constructor(
if (!config.computeReactions) {
return TimelineItemReactions(reactions = persistentListOf())
}
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = this.event.reactions.map { reaction ->
// Sort reactions within an aggregation by timestamp descending.
// This puts the most recent at the top, useful in cases like the
@@ -121,7 +122,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
AggregatedReactionSender(
senderId = it.senderId,
timestamp = date,
sentTime = timeFormatter.format(date),
sentTime = dateFormatter.format(
it.timestamp,
DateFormatterMode.TimeOrDate,
),
)
}
.toImmutableList()
@@ -157,7 +161,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
url = roomMember?.avatarUrl,
size = AvatarSize.TimelineReadReceipt,
),
formattedDate = lastMessageTimestampFormatter.format(receipt.timestamp)
formattedDate = dateFormatter.format(
receipt.timestamp,
mode = DateFormatterMode.TimeOrDate,
)
)
}
.toImmutableList()

View File

@@ -9,13 +9,20 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import javax.inject.Inject
class TimelineItemDaySeparatorFactory @Inject constructor(private val daySeparatorFormatter: DaySeparatorFormatter) {
class TimelineItemDaySeparatorFactory @Inject constructor(
private val dateFormatter: DateFormatter,
) {
fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel {
val formattedDate = daySeparatorFormatter.format(virtualItem.timestamp)
val formattedDate = dateFormatter.format(
timestamp = virtualItem.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
return TimelineItemDaySeparatorModel(
formattedDate = formattedDate
)

View File

@@ -71,6 +71,7 @@ sealed interface TimelineItem {
val senderProfile: ProfileTimelineDetails,
val senderAvatar: AvatarData,
val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L,
val sentTime: String = "",
val isMine: Boolean = false,
val isEditable: Boolean,

View File

@@ -36,7 +36,13 @@ open class TimelineItemEncryptedContentProvider : PreviewParameterProvider<Timel
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessage,
utdCause = UtdCause.HistoricalMessageAndBackupIsDisabled,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessageAndDeviceIsUnverified,
)
),
aTimelineItemEncryptedContent(

View File

@@ -21,7 +21,6 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
@@ -29,6 +28,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService

View File

@@ -8,11 +8,6 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
@@ -23,17 +18,10 @@ import dagger.multibindings.IntoMap
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Module
@ContributesTo(RoomScope::class)
@@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
}
class VoiceMessagePresenter @AssistedInject constructor(
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
private val analyticsService: AnalyticsService,
private val scope: CoroutineScope,
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@AssistedFactory
@@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
}
private val player = voiceMessagePlayerFactory.create(
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
eventId = content.eventId,
mediaSource = content.mediaSource,
mimeType = content.mimeType,
filename = content.filename,
duration = content.duration,
)
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
@Composable
override fun present(): VoiceMessageState {
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
val button by remember {
derivedStateOf {
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
}
}
}
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
}
val progress by remember {
derivedStateOf {
playerState.currentPosition / duration.toFloat()
}
}
val time by remember {
derivedStateOf {
when {
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
playerState.currentPosition > 0 -> playerState.currentPosition
else -> duration
}.milliseconds.formatShort()
}
}
val showCursor by remember {
derivedStateOf {
!play.value.isUninitialized() && !playerState.isEnded
}
}
fun eventSink(event: VoiceMessageEvents) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
player.play()
} else {
scope.launch {
play.runUpdatingState(
errorTransform = {
analyticsService.trackError(
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
)
it
},
) {
player.prepare().flatMap {
runCatching { player.play() }
}
}
}
}
}
is VoiceMessageEvents.Seek -> {
player.seekTo((event.percentage * duration).toLong())
}
}
}
return VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
)
return presenter.present()
}
}

View File

@@ -31,6 +31,7 @@
<string name="screen_room_timeline_add_reaction">"Emodzsi hozzáadása"</string>
<string name="screen_room_timeline_beginning_of_room">"Ez a(z) %1$s kezdete."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Ez a beszélgetés kezdete."</string>
<string name="screen_room_timeline_legacy_call">"Nem támogatott hívás. Kérdezze meg, hogy a hívó fél tudja-e használni az új Element X alkalmazást."</string>
<string name="screen_room_timeline_less_reactions">"Kevesebb megjelenítése"</string>
<string name="screen_room_timeline_message_copied">"Üzenet másolva"</string>
<string name="screen_room_timeline_no_permission_to_post">"Nincs jogosultsága arra, hogy bejegyzést tegyen közzé ebben a szobában"</string>

View File

@@ -31,6 +31,7 @@
<string name="screen_room_timeline_add_reaction">"Aggiungi emoji"</string>
<string name="screen_room_timeline_beginning_of_room">"Questo è l\'inizio di %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Questo è l\'inizio della conversazione."</string>
<string name="screen_room_timeline_legacy_call">"Chiamata non supportata. Chiedi se il chiamante può utilizzare la nuova app Element X."</string>
<string name="screen_room_timeline_less_reactions">"Mostra meno"</string>
<string name="screen_room_timeline_message_copied">"Messaggio copiato"</string>
<string name="screen_room_timeline_no_permission_to_post">"Non sei autorizzato a postare in questa stanza"</string>

View File

@@ -467,7 +467,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent())))
awaitItem()
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}

View File

@@ -327,6 +327,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit),
verifiedUserSendFailure = VerifiedUserSendFailure.None,
@@ -399,6 +400,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(TimelineItemAction.Edit),
@@ -427,6 +429,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
verifiedUserSendFailure = aChangedIdentitySendFailure(),
actions = persistentListOf(),
@@ -533,6 +536,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = {}
)
}
}

View File

@@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -86,6 +87,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -128,6 +130,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -170,13 +173,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -215,13 +219,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -263,12 +268,13 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -308,13 +314,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -355,13 +362,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -403,14 +411,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@@ -448,14 +457,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@@ -496,14 +506,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
)
@@ -542,14 +553,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.AddCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -592,6 +604,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -599,8 +612,8 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
// Not here
// TimelineItemAction.AddCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -641,14 +654,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
@@ -691,13 +705,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@@ -738,6 +753,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = stateEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -808,14 +824,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
@@ -855,13 +872,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@@ -909,14 +927,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Unpin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Unpin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@@ -1006,6 +1025,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -1046,14 +1066,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.EditPoll,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@@ -1089,13 +1110,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@@ -1131,12 +1153,13 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@@ -1174,13 +1197,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@@ -1214,6 +1238,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@@ -1268,6 +1293,7 @@ private fun createActionListPresenter(
initialState = mapOf(
FeatureFlags.MediaCaptionCreation.key to allowCaption,
),
)
),
dateFormatter = FakeDateFormatter(),
)
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.actionlist.model
import org.junit.Test
class TimelineItemActionComparatorTest {
@Test
fun `check that the list in the comparator only contain each item once`() {
val sut = TimelineItemActionComparator()
sut.orderedList.forEach {
require(sut.orderedList.count { item -> item == it } == 1, { "Duplicate ${it::class.java}.$it" })
}
}
@Test
fun `check that the list in the comparator contains all the items`() {
val sut = TimelineItemActionComparator()
TimelineItemAction.entries.forEach {
require(it in sut.orderedList, { "Missing ${it::class.simpleName}.$it in orderedList" })
}
}
}

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