Merge branch 'release/0.7.6' into main
This commit is contained in:
2
.github/workflows/generate_github_pages.yml
vendored
2
.github/workflows/generate_github_pages.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/recordScreenshots.yml
vendored
4
.github/workflows/recordScreenshots.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/validate-lfs.yml
vendored
2
.github/workflows/validate-lfs.yml
vendored
@@ -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
2
.idea/kotlinc.xml
generated
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
69
CHANGES.md
69
CHANGES.md
@@ -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)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/40007060.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40007060.txt
Normal 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
|
||||
@@ -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: $ & \'() * +/; =? @ [] - . _"</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>
|
||||
|
||||
@@ -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 ! $ & \'() * +/; =? @ [] - . _"</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 d’accès à ce salon?"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_description">"Êtes-vous sûr de vouloir annuler votre demande d’accès à ce salon ?"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_title">"Annuler la demande d’adhé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 l’historique 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 n’est pas disponible"</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
features/knockrequests/api/build.gradle.kts
Normal file
19
features/knockrequests/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
47
features/knockrequests/impl/build.gradle.kts
Normal file
47
features/knockrequests/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 n’ont pas pu être acceptées. Voulez-vous réessayer ?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Toutes les demandes n’ont 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 n’a pas pu être acceptée. Voulez-vous réessayer ?"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_title">"Impossible d’accepter 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 l’accè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 l’accè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 n’avons 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 quelqu’un 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
36
features/knockrequests/impl/src/main/res/values/localazy.xml
Normal file
36
features/knockrequests/impl/src/main/res/values/localazy.xml
Normal 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 couldn’t 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 couldn’t 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 won’t 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 couldn’t 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, you’ll 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>
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 à l’avenir, y compris vous."</string>
|
||||
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon n’est 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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class DependencyLicensesListState(
|
||||
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
|
||||
val filter: String,
|
||||
val eventSink: (DependencyLicensesListEvent) -> Unit,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 l’application à chaque fois."</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 l’accès à l’historique de vos messages."</string>
|
||||
<string name="screen_signout_recovery_disabled_title">"La récupération n’est 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 l’accès à l’historique 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -37,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||
event = aTimelineItemEvent(
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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:00 AM",
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,5 +40,6 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user