Merge branch 'develop' into feature/valere/message_shields

This commit is contained in:
Benoit Marty 2024-08-14 12:37:31 +02:00 committed by GitHub
commit 5dceba8146
342 changed files with 5475 additions and 1377 deletions

View file

@ -36,7 +36,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay APK - name: Assemble debug Gplay APK

View file

@ -44,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK

View file

@ -19,7 +19,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -1,15 +0,0 @@
name: "Validate Gradle Wrapper"
on:
pull_request:
merge_group:
push:
branches: [ main, develop ]
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v3

View file

@ -39,7 +39,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK

View file

@ -27,7 +27,7 @@ jobs:
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: false cache-read-only: false
@ -67,7 +67,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@ -90,7 +90,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@ -130,7 +130,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@ -174,7 +174,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@ -214,7 +214,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
@ -254,7 +254,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Knit - name: Run Knit

View file

@ -39,7 +39,7 @@ jobs:
java-version: '17' java-version: '17'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
- name: Create Enterprise app bundle - name: Create Enterprise app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -89,7 +89,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -33,7 +33,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug

View file

@ -18,7 +18,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

2
.idea/kotlinc.xml generated
View file

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

View file

@ -1,7 +1,7 @@
[![Latest build](https://github.com/element-hq/element-x-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/element-hq/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop) [![Latest build](https://github.com/element-hq/element-x-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/element-hq/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=element-x-android)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=element-x-android)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=element-x-android)
[![codecov](https://codecov.io/github/element-hq/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/vector-im/element-x-android) [![codecov](https://codecov.io/github/element-hq/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/vector-im/element-x-android)
[![Element X Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org) [![Element X Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org)
[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element) [![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element)

View file

@ -40,3 +40,5 @@
-keepclassmembers class android.view.JavaViewSpy { -keepclassmembers class android.view.JavaViewSpy {
static int windowAttachCount(android.view.View); static int windowAttachCount(android.view.View);
} }
-keep class io.element.android.x.di.** { *; }

View file

@ -21,6 +21,7 @@
<locale android:name="sk"/> <locale android:name="sk"/>
<locale android:name="sv"/> <locale android:name="sv"/>
<locale android:name="uk"/> <locale android:name="uk"/>
<locale android:name="uz"/>
<locale android:name="zh-CN"/> <locale android:name="zh-CN"/>
<locale android:name="zh-TW"/> <locale android:name="zh-TW"/>
</locale-config> </locale-config>

View file

@ -31,7 +31,6 @@ object TimelineConfig {
StateEventType.ROOM_GUEST_ACCESS, StateEventType.ROOM_GUEST_ACCESS,
StateEventType.ROOM_HISTORY_VISIBILITY, StateEventType.ROOM_HISTORY_VISIBILITY,
StateEventType.ROOM_JOIN_RULES, StateEventType.ROOM_JOIN_RULES,
StateEventType.ROOM_PINNED_EVENTS,
StateEventType.ROOM_POWER_LEVELS, StateEventType.ROOM_POWER_LEVELS,
StateEventType.ROOM_SERVER_ACL, StateEventType.ROOM_SERVER_ACL,
StateEventType.ROOM_TOMBSTONE, StateEventType.ROOM_TOMBSTONE,

View file

@ -61,7 +61,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.5") detektPlugins("io.nlopez.compose.rules:detekt:0.4.10")
} }
// KtLint // KtLint
@ -129,11 +129,11 @@ dependencyAnalysis {
// To run a sonar analysis: // To run a sonar analysis:
// Run './gradlew sonar -Dsonar.login=<SONAR_LOGIN>' // Run './gradlew sonar -Dsonar.login=<SONAR_LOGIN>'
// The SONAR_LOGIN is stored in passbolt as Token Sonar Cloud Bma // The SONAR_LOGIN is stored in passbolt as Token Sonar Cloud Bma
// Sonar result can be found here: https://sonarcloud.io/project/overview?id=vector-im_element-x-android // Sonar result can be found here: https://sonarcloud.io/project/overview?id=element-x-android
sonar { sonar {
properties { properties {
property("sonar.projectName", "element-x-android") property("sonar.projectName", "element-x-android")
property("sonar.projectKey", "vector-im_element-x-android") property("sonar.projectKey", "element-x-android")
property("sonar.host.url", "https://sonarcloud.io") property("sonar.host.url", "https://sonarcloud.io")
property("sonar.projectVersion", "1.0") // TODO project(":app").android.defaultConfig.versionName) property("sonar.projectVersion", "1.0") // TODO project(":app").android.defaultConfig.versionName)
property("sonar.sourceEncoding", "UTF-8") property("sonar.sourceEncoding", "UTF-8")
@ -141,7 +141,7 @@ sonar {
property("sonar.links.ci", "https://github.com/element-hq/element-x-android/actions") property("sonar.links.ci", "https://github.com/element-hq/element-x-android/actions")
property("sonar.links.scm", "https://github.com/element-hq/element-x-android/") property("sonar.links.scm", "https://github.com/element-hq/element-x-android/")
property("sonar.links.issue", "https://github.com/element-hq/element-x-android/issues") property("sonar.links.issue", "https://github.com/element-hq/element-x-android/issues")
property("sonar.organization", "new_vector_ltd_organization") property("sonar.organization", "element-hq")
property("sonar.login", if (project.hasProperty("SONAR_LOGIN")) project.property("SONAR_LOGIN")!! else "invalid") property("sonar.login", if (project.hasProperty("SONAR_LOGIN")) project.property("SONAR_LOGIN")!! else "invalid")
// exclude source code from analyses separated by a colon (:) // exclude source code from analyses separated by a colon (:)

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."</string> <string name="screen_analytics_settings_help_us_improve">"Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."</string>
<string name="screen_analytics_settings_read_terms">"Możesz przeczytać wszystkie nasze warunki %1$s."</string> <string name="screen_analytics_settings_read_terms">"Przeczytaj nasze warunki użytkowania %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"tutaj"</string> <string name="screen_analytics_settings_read_terms_content_link">"tutaj"</string>
<string name="screen_analytics_settings_share_data">"Udostępniaj dane analityczne"</string> <string name="screen_analytics_settings_share_data">"Udostępniaj dane analityczne"</string>
</resources> </resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring."</string>
<string name="screen_analytics_settings_read_terms">"Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"Bu yerga"</string>
<string name="screen_analytics_settings_share_data">"Analitik ma\'lumotlarni ulashish"</string>
</resources>

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Nie będziemy rejestrować ani profilować żadnych danych osobistych"</string> <string name="screen_analytics_prompt_data_usage">"Nie będziemy rejestrować ani profilować żadnych danych osobistych"</string>
<string name="screen_analytics_prompt_help_us_improve">"Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."</string> <string name="screen_analytics_prompt_help_us_improve">"Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."</string>
<string name="screen_analytics_prompt_read_terms">"Możesz przeczytać wszystkie nasze warunki %1$s."</string> <string name="screen_analytics_prompt_read_terms">"Przeczytaj nasze warunki użytkowania %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"tutaj"</string> <string name="screen_analytics_prompt_read_terms_content_link">"tutaj"</string>
<string name="screen_analytics_prompt_settings">"Możesz to wyłączyć w dowolnym momencie"</string> <string name="screen_analytics_prompt_settings">"Możesz to wyłączyć w dowolnym momencie"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Nie będziemy udostępniać Twoich danych podmiotom trzecim"</string> <string name="screen_analytics_prompt_third_party_sharing">"Nie będziemy udostępniać Twoich danych podmiotom trzecim"</string>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Biz hech qanday shaxsiy ma\'lumotlarni yozmaymiz yoki profilga kiritmaymiz"</string>
<string name="screen_analytics_prompt_help_us_improve">"Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring."</string>
<string name="screen_analytics_prompt_read_terms">"Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"Bu yerga"</string>
<string name="screen_analytics_prompt_settings">"Buni istalgan vaqtda oʻchirib qoʻyishingiz mumkin"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Biz sizning ma\'lumotlaringizni uchinchi tomonlar bilan baham ko\'rmaymiz"</string>
<string name="screen_analytics_prompt_title">"Yaxshilashga yordam bering%1$s"</string>
</resources>

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Połączenie w trakcie"</string> <string name="call_foreground_service_channel_title_android">"Połączenie w trakcie"</string>
<string name="call_foreground_service_message_android">"Stuknij, aby wrócić do rozmowy"</string> <string name="call_foreground_service_message_android">"Stuknij, aby wrócić do rozmowy"</string>
<string name="call_foreground_service_title_android">"☎️ Rozmowa w toku"</string> <string name="call_foreground_service_title_android">"☎️ Rozmowa w toku"</string>
<string name="screen_incoming_call_subtitle_android">"Przychodzące połączenie Element"</string>
</resources> </resources>

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Pågående samtal"</string> <string name="call_foreground_service_channel_title_android">"Pågående samtal"</string>
<string name="call_foreground_service_message_android">"Tryck för att återgå till samtalet"</string> <string name="call_foreground_service_message_android">"Tryck för att återgå till samtalet"</string>
<string name="call_foreground_service_title_android">"☎️ Samtal pågår"</string> <string name="call_foreground_service_title_android">"☎️ Samtal pågår"</string>
<string name="screen_incoming_call_subtitle_android">"Inkommande Element Call"</string>
</resources> </resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Davom etayotgan qo\'ng\'iroq"</string>
<string name="call_foreground_service_message_android">"Qo\'ng\'iroqqa qaytish uchun bosing"</string>
<string name="call_foreground_service_title_android">"☎️ Qongiroq davom etmoqda"</string>
</resources>

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.analytics.api.ScreenTracker
@ -43,11 +44,13 @@ import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -86,8 +89,9 @@ class CallScreenPresenterTest {
@Test @Test
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest { fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
val sendCallNotificationIfNeededLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) } val sendCallNotificationIfNeededLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda) val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
val client = FakeMatrixClient().apply { val client = FakeMatrixClient(syncService = syncService).apply {
givenGetRoomResult(A_ROOM_ID, fakeRoom) givenGetRoomResult(A_ROOM_ID, fakeRoom)
} }
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
@ -216,7 +220,12 @@ class CallScreenPresenterTest {
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val matrixClient = FakeMatrixClient() val syncStateFlow = MutableStateFlow(SyncState.Idle)
val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = syncStateFlow).apply {
this.startSyncLambda = startSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
@ -230,7 +239,7 @@ class CallScreenPresenterTest {
}.test { }.test {
consumeItemsUntilTimeout() consumeItemsUntilTimeout()
assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Running) assert(startSyncLambda).isCalledOnce()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -240,7 +249,12 @@ class CallScreenPresenterTest {
fun `present - automatically stops the Matrix client sync on dispose`() = runTest { fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val matrixClient = FakeMatrixClient() val syncStateFlow = MutableStateFlow(SyncState.Running)
val stopSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = syncStateFlow).apply {
this.stopSyncLambda = stopSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
@ -262,7 +276,7 @@ class CallScreenPresenterTest {
job.cancelAndJoin() job.cancelAndJoin()
assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Terminated) assert(stopSyncLambda).isCalledOnce()
} }
private fun TestScope.createCallScreenPresenter( private fun TestScope.createCallScreenPresenter(

View file

@ -6,7 +6,7 @@
<string name="screen_create_room_private_option_description">"Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."</string> <string name="screen_create_room_private_option_description">"Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."</string>
<string name="screen_create_room_private_option_title">"Pokój prywatny (tylko zaproszenie)"</string> <string name="screen_create_room_private_option_title">"Pokój prywatny (tylko zaproszenie)"</string>
<string name="screen_create_room_public_option_description">"Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."</string> <string name="screen_create_room_public_option_description">"Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."</string>
<string name="screen_create_room_public_option_title">"Pokój publiczny (każdy)"</string> <string name="screen_create_room_public_option_title">"Pokój publiczny (wszyscy)"</string>
<string name="screen_create_room_room_name_label">"Nazwa pokoju"</string> <string name="screen_create_room_room_name_label">"Nazwa pokoju"</string>
<string name="screen_create_room_title">"Utwórz pokój"</string> <string name="screen_create_room_title">"Utwórz pokój"</string>
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string> <string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Yangi xona"</string>
<string name="screen_create_room_add_people_title">"Odamlarni taklif qiling"</string>
<string name="screen_create_room_error_creating_room">"Xonani yaratishda xatolik yuz berdi"</string>
<string name="screen_create_room_private_option_description">"Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni ochirib bolmaydi."</string>
<string name="screen_create_room_private_option_title">"Shaxsiy xona (faqat taklif)"</string>
<string name="screen_create_room_public_option_description">"Xabarlar shifrlanmagan va har kim ularni o\'qiy oladi. Keyinchalik shifrlashni yoqishingiz mumkin."</string>
<string name="screen_create_room_public_option_title">"Jamoat xonasi (har kim)"</string>
<string name="screen_create_room_room_name_label">"Xona nomi"</string>
<string name="screen_create_room_title">"Xonani yaratish"</string>
<string name="screen_create_room_topic_label">"Mavzu (ixtiyoriy)"</string>
<string name="screen_start_chat_error_starting_chat">"Suhbatni boshlashda xatolik yuz berdi"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Sozlamalaringizni keyinroq o\'zgartirishingiz mumkin."</string>
<string name="screen_notification_optin_title">"Bildirishnomalarga ruxsat bering va hech qachon xabarni o\'tkazib yubormang"</string>
<string name="screen_welcome_bullet_1">"Qo\'ng\'iroqlar, so\'ro\'vlar, qidiruv va boshqalar shu yil oxirida qo\'shiladi."</string>
<string name="screen_welcome_bullet_2">"Shifrlangan xonalar uchun xabarlar tarixi hali mavjud emas."</string>
<string name="screen_welcome_bullet_3">"Biz sizdan eshitishni istardik, sozlamalar sahifasi orqali fikringizni bildiring."</string>
<string name="screen_welcome_button">"Qani ketdik!"</string>
<string name="screen_welcome_subtitle">"Buni bilishingiz kerak:"</string>
<string name="screen_welcome_title">"%1$sga Xush kelibsiz!"</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Czy na pewno chcesz odrzucić zaproszenie do dołączenia do %1$s?"</string> <string name="screen_invites_decline_chat_message">"Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odrzuć zaproszenie"</string> <string name="screen_invites_decline_chat_title">"Odrzuć zaproszenie"</string>
<string name="screen_invites_decline_direct_chat_message">"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odrzuć czat"</string> <string name="screen_invites_decline_direct_chat_title">"Odrzuć czat"</string>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Taklifni rad etish"</string>
<string name="screen_invites_decline_direct_chat_message">"Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chatni rad etish"</string>
<string name="screen_invites_empty_list">"Takliflar yo\'q"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) sizni taklif qildi"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Dołącz do pokoju"</string>
<string name="screen_join_room_knock_action">"Zapukaj, by dołączyć"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web."</string>
<string name="screen_join_room_space_not_supported_title">"Przestrzenie nie są jeszcze obsługiwane"</string>
<string name="screen_join_room_subtitle_knock">"Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy."</string>
<string name="screen_join_room_subtitle_no_preview">"Musisz być członkiem tego pokoju, aby wyświetlić historię wiadomości."</string>
<string name="screen_join_room_title_knock">"Chcesz dołączyć do tego pokoju?"</string>
<string name="screen_join_room_title_no_preview">"Podgląd nie jest dostępny"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Gå med i rummet"</string>
<string name="screen_join_room_knock_action">"Knacka för att gå med"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s stöder inte utrymmen än. Du kan komma åt utrymmen på webben."</string>
<string name="screen_join_room_space_not_supported_title">"Utrymmen stöds inte ännu"</string>
<string name="screen_join_room_subtitle_knock">"Klicka på knappen nedan så kommer en rumsadministratör att meddelas. Du kommer att kunna gå med i konversationen när den har godkänts."</string>
<string name="screen_join_room_subtitle_no_preview">"Du måste vara medlem i det här rummet för att se meddelandehistoriken."</string>
<string name="screen_join_room_title_knock">"Vill du gå med i det här rummet?"</string>
<string name="screen_join_room_title_no_preview">"Förhandsgranskning är inte tillgänglig"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Приєднатися до кімнати"</string>
<string name="screen_join_room_knock_action">"Постукати, щоб приєднатися"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s ще не підтримує простори. Ви можете отримати доступ до них в вебверсії."</string>
<string name="screen_join_room_space_not_supported_title">"Простори поки що не підтримуються"</string>
<string name="screen_join_room_subtitle_knock">"Натисніть кнопку нижче, і адміністратор кімнати отримає сповіщення. Ви зможете приєднатися до розмови після схвалення."</string>
<string name="screen_join_room_subtitle_no_preview">"Ви мусите бути учасником цієї кімнати, щоб переглядати історію повідомлень."</string>
<string name="screen_join_room_title_knock">"Хочете приєднатися до цієї кімнати?"</string>
<string name="screen_join_room_title_no_preview">"Попередній перегляд недоступний"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Siz bu yerda yagona odamsiz. Agar siz tark etsangiz, kelajakda hech kim qo\'shila olmaydi, jumladan siz ham."</string>
<string name="leave_room_alert_private_subtitle">"Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu xona ochiq emas va siz taklifsiz qayta qoshila olmaysiz."</string>
<string name="leave_room_alert_subtitle">"Xonani tark etmoqchi ekanligingizga ishonchingiz komilmi?"</string>
</resources>

View file

@ -41,6 +41,7 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api) implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
@ -59,4 +60,5 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
} }

View file

@ -29,7 +29,7 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockMana
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.features.lockscreen.impl.unlock.signout.SignOut import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -41,7 +41,7 @@ import javax.inject.Inject
class PinUnlockPresenter @Inject constructor( class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager, private val biometricUnlockManager: BiometricUnlockManager,
private val signOut: SignOut, private val logoutUseCase: LogoutUseCase,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper, private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@ -179,7 +179,7 @@ class PinUnlockPresenter @Inject constructor(
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncData<String?>>) = launch { private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncData<String?>>) = launch {
suspend { suspend {
signOut() logoutUseCase.logout(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction) }.runCatchingUpdatingState(signOutAction)
} }
} }

View file

@ -18,9 +18,9 @@ package io.element.android.features.lockscreen.impl.unlock.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
@ContributesTo(AppScope::class) @ContributesTo(SessionScope::class)
interface PinUnlockBindings { interface PinUnlockBindings {
fun inject(activity: PinUnlockActivity) fun inject(activity: PinUnlockActivity)
} }

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.unlock.signout
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultSignOut @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val matrixClientProvider: MatrixClientProvider,
) : SignOut {
override suspend fun invoke(): String? {
val currentSession = authenticationService.getLatestSessionId()
return if (currentSession != null) {
matrixClientProvider.getOrRestore(currentSession)
.getOrThrow()
.logout(ignoreSdkError = true)
} else {
error("No session to sign out")
}
}
}

View file

@ -16,11 +16,11 @@
<string name="screen_app_lock_setup_confirm_pin">"Potwierdź PIN"</string> <string name="screen_app_lock_setup_confirm_pin">"Potwierdź PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów. <string name="screen_app_lock_setup_pin_context">"Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów.
Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PINU, zostaniesz wylogowany z aplikacji."</string> Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PIN\'u, zostaniesz wylogowany z aplikacji."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Nie możesz wybrać tego PINU ze względów bezpieczeństwa"</string> <string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Nie możesz wybrać tego PIN\'u ze względów bezpieczeństwa"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Wybierz inny kod PIN"</string> <string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Wybierz inny kod PIN"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Wprowadź ten sam kod PIN dwa razy"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Wprowadź ten sam kod PIN dwa razy"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINY nie pasują do siebie"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN\'y nie pasują do siebie"</string>
<string name="screen_app_lock_signout_alert_message">"Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN"</string> <string name="screen_app_lock_signout_alert_message">"Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN"</string>
<string name="screen_app_lock_signout_alert_title">"Trwa wylogowywanie"</string> <string name="screen_app_lock_signout_alert_title">"Trwa wylogowywanie"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">

View file

@ -1,14 +1,27 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"autenticação por biometria"</string>
<string name="screen_app_lock_biometric_unlock">"desbloqueio por biometria"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Desbloquear com biometria"</string>
<string name="screen_app_lock_forgot_pin">"Esqueceu o PIN?"</string> <string name="screen_app_lock_forgot_pin">"Esqueceu o PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Mudar código de PIN"</string> <string name="screen_app_lock_settings_change_pin">"Alterar código de PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Permitir desbloqueio biométrico"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Permitir desbloqueio biométrico"</string>
<string name="screen_app_lock_settings_remove_pin">"Remover PIN"</string> <string name="screen_app_lock_settings_remove_pin">"Remover PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Tem certeza de que quer remover o PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Tem certeza de que quer remover o PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Remover PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_title">"Remover PIN?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Permitir %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Prefiro usar o PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Poupe tempo e use %1$s para desbloquear o aplicativo todas as vezes"</string>
<string name="screen_app_lock_setup_choose_pin">"Escolher PIN"</string> <string name="screen_app_lock_setup_choose_pin">"Escolher PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Confirmar PIN"</string> <string name="screen_app_lock_setup_confirm_pin">"Confirmar PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Bloqueie o %1$s para adicionar uma segurança extra às suas conversas.
Escolha algo memorável. Se você esquecer este PIN, você será desconectado do app."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Você não pode escolher este PIN por razões de segurança"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Escolha um PIN diferente"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Por favor, insira o mesmo PIN duas vezes"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Os PINs não correspondem"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Os PINs não correspondem"</string>
<string name="screen_app_lock_signout_alert_message">"Você terá que fazer login novamente e criar um novo PIN para prosseguir"</string>
<string name="screen_app_lock_signout_alert_title">"Você está sendo desconectado"</string> <string name="screen_app_lock_signout_alert_title">"Você está sendo desconectado"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Você tem %1$d tentativa de debloqueio"</item> <item quantity="one">"Você tem %1$d tentativa de debloqueio"</item>
@ -18,5 +31,7 @@
<item quantity="one">"PIN incorreto. Você tem mais %1$d chance"</item> <item quantity="one">"PIN incorreto. Você tem mais %1$d chance"</item>
<item quantity="other">"PIN incorreto. Você tem mais %1$d chances"</item> <item quantity="other">"PIN incorreto. Você tem mais %1$d chances"</item>
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Usar biometria"</string>
<string name="screen_app_lock_use_pin_android">"Usar PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string> <string name="screen_signout_in_progress_dialog_content">"Saindo…"</string>
</resources> </resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Chiqish…"</string>
</resources>

View file

@ -1,61 +0,0 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.unlock
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.unlock.signout.DefaultSignOut
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSignOutTest {
private val matrixClient = FakeMatrixClient()
private val authenticationService = FakeMatrixAuthenticationService()
private val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
private val sut = DefaultSignOut(authenticationService, matrixClientProvider)
@Test
fun `when no active session then it throws`() = runTest {
authenticationService.getLatestSessionIdLambda = { null }
val result = runCatching { sut.invoke() }
assertThat(result.isFailure).isTrue()
}
@Test
fun `with one active session and successful logout on client`() = runTest {
val logoutLambda = lambdaRecorder<Boolean, String?> { _: Boolean -> null }
authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId }
matrixClient.logoutLambda = logoutLambda
val result = runCatching { sut.invoke() }
assertThat(result.isSuccess).isTrue()
assert(logoutLambda).isCalledOnce()
}
@Test
fun `with one active session and and failed logout on client`() = runTest {
val logoutLambda = lambdaRecorder<Boolean, String?> { _: Boolean -> error("Failed to logout") }
authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId }
matrixClient.logoutLambda = logoutLambda
val result = runCatching { sut.invoke() }
assertThat(result.isFailure).isTrue()
assert(logoutLambda).isCalledOnce()
}
}

View file

@ -28,7 +28,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.features.lockscreen.impl.unlock.signout.SignOut import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -106,9 +106,9 @@ class PinUnlockPresenterTest {
@Test @Test
fun `present - forgot pin flow`() = runTest { fun `present - forgot pin flow`() = runTest {
val signOutLambda = lambdaRecorder<String?> { null } val signOutLambda = lambdaRecorder<Boolean, String> { "" }
val signOut = FakeSignOut(signOutLambda) val signOut = FakeLogoutUseCase(signOutLambda)
val presenter = createPinUnlockPresenter(this, signOut = signOut) val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -135,7 +135,7 @@ class PinUnlockPresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java) assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java)
} }
assert(signOutLambda).isCalledOnce().withNoParameter() assert(signOutLambda).isCalledOnce()
} }
} }
@ -147,7 +147,7 @@ class PinUnlockPresenterTest {
scope: CoroutineScope, scope: CoroutineScope,
biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(), biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
signOut: SignOut = FakeSignOut(), logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
): PinUnlockPresenter { ): PinUnlockPresenter {
val pinCodeManager = aPinCodeManager().apply { val pinCodeManager = aPinCodeManager().apply {
addCallback(callback) addCallback(callback)
@ -156,7 +156,7 @@ class PinUnlockPresenterTest {
return PinUnlockPresenter( return PinUnlockPresenter(
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
biometricUnlockManager = biometricUnlockManager, biometricUnlockManager = biometricUnlockManager,
signOut = signOut, logoutUseCase = logoutUseCase,
coroutineScope = scope, coroutineScope = scope,
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager), pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
) )

View file

@ -6,7 +6,7 @@
<string name="screen_account_provider_form_subtitle">"Szukaj serwera firmowego, społeczności lub prywatnego."</string> <string name="screen_account_provider_form_subtitle">"Szukaj serwera firmowego, społeczności lub prywatnego."</string>
<string name="screen_account_provider_form_title">"Znajdź dostawcę konta"</string> <string name="screen_account_provider_form_title">"Znajdź dostawcę konta"</string>
<string name="screen_account_provider_signin_subtitle">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string> <string name="screen_account_provider_signin_subtitle">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string>
<string name="screen_account_provider_signin_title">"Zamierzasz się zalogować %s"</string> <string name="screen_account_provider_signin_title">"Zamierzasz zalogować się do %s"</string>
<string name="screen_account_provider_signup_subtitle">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string> <string name="screen_account_provider_signup_subtitle">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string>
<string name="screen_account_provider_signup_title">"Zamierzasz założyć konto na %s"</string> <string name="screen_account_provider_signup_title">"Zamierzasz założyć konto na %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org jest ogromnym i darmowym serwerem na publicznej sieci Matrix zapewniający bezpieczną i zdecentralizowaną komunikację zarządzaną przez Fundację Matrix.org."</string> <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org jest ogromnym i darmowym serwerem na publicznej sieci Matrix zapewniający bezpieczną i zdecentralizowaną komunikację zarządzaną przez Fundację Matrix.org."</string>
@ -14,20 +14,64 @@
<string name="screen_change_account_provider_subtitle">"Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."</string> <string name="screen_change_account_provider_subtitle">"Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."</string>
<string name="screen_change_account_provider_title">"Zmień dostawcę konta"</string> <string name="screen_change_account_provider_title">"Zmień dostawcę konta"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."</string> <string name="screen_change_server_error_invalid_homeserver">"Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync nie jest dostępny z powodu problemu w znanym pliku:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Ten serwer obecnie nie obsługuje technologii Sliding Sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Ten serwer obecnie nie obsługuje technologii Sliding Sync."</string>
<string name="screen_change_server_form_header">"Adres URL serwera domowego"</string> <string name="screen_change_server_form_header">"URL serwera domowego"</string>
<string name="screen_change_server_form_notice">"Możesz połączyć się tylko z serwerem, który obsługuje technologię Sliding Sync. Administrator serwera domowego będzie musiał ją skonfigurować. %1$s"</string> <string name="screen_change_server_form_notice">"Możesz połączyć się tylko z serwerem, który obsługuje technologię Sliding Sync. Administrator serwera domowego będzie musiał ją skonfigurować. %1$s"</string>
<string name="screen_change_server_subtitle">"Jaki jest adres Twojego serwera?"</string> <string name="screen_change_server_subtitle">"Jaki jest adres Twojego serwera?"</string>
<string name="screen_change_server_title">"Wybierz swój serwer"</string> <string name="screen_change_server_title">"Wybierz swój serwer"</string>
<string name="screen_login_error_deactivated_account">"To konto zostało dezaktywowane."</string> <string name="screen_login_error_deactivated_account">"To konto zostało dezaktywowane."</string>
<string name="screen_login_error_invalid_credentials">"Nieprawidłowa nazwa użytkownika i/lub hasło"</string> <string name="screen_login_error_invalid_credentials">"Nieprawidłowa nazwa użytkownika i/lub hasło"</string>
<string name="screen_login_error_invalid_user_id">"To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Ten serwer został skonfigurowany do korzystania z tokenów odświeżania. Nie są one obsługiwane, gdy korzystasz z hasła."</string>
<string name="screen_login_error_unsupported_authentication">"Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy."</string> <string name="screen_login_error_unsupported_authentication">"Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy."</string>
<string name="screen_login_form_header">"Wprowadź swoje dane"</string> <string name="screen_login_form_header">"Wprowadź swoje dane"</string>
<string name="screen_login_subtitle">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string> <string name="screen_login_subtitle">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string>
<string name="screen_login_title">"Witaj ponownie!"</string> <string name="screen_login_title">"Witaj ponownie!"</string>
<string name="screen_login_title_with_homeserver">"Zaloguj się do %1$s"</string> <string name="screen_login_title_with_homeserver">"Zaloguj się do %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Nawiązanie bezpiecznego połączenia"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teraz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Jeśli napotkasz ten sam problem, użyj innej sieci Wi-FI lub danych mobilnych"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Jeśli to nie zadziała, zaloguj się ręcznie"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Połączenie nie jest bezpieczne"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Zostaniesz poproszony o wprowadzenie dwóch cyfr widocznych na tym urządzeniu."</string>
<string name="screen_qr_code_login_device_code_title">"Wprowadź numer poniżej na innym urządzeniu"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Zaloguj się na drugie urządzenie lub użyj tego, które jest już zalogowane, a następnie spróbuj ponownie."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Drugie urządzenie nie jest zalogowane"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Logowanie zostało anulowane na drugim urządzeniu."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Prośba o logowanie została anulowana"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Logowanie zostało odrzucone na drugim urządzeniu."</string>
<string name="screen_qr_code_login_error_declined_title">"Logowanie odrzucone"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Logowanie wygasło. Spróbuj ponownie."</string>
<string name="screen_qr_code_login_error_expired_title">"Logowanie nie zostało ukończone na czas"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR.
Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Kod QR nie jest wspierany"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Twój dostawca konta nie obsługuje %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s nie jest wspierany"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Gotowy do skanowania"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Otwórz %1$s na urządzeniu stacjonarnym"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kliknij na swój awatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Wybierz %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Powiąż nowe urządzenie”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
<string name="screen_qr_code_login_initial_state_title">"Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Użyj kodu QR widocznego na drugim urządzeniu."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Spróbuj ponownie"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Spróbuj ponownie"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Błędny kod QR"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Przejdź do ustawień aparatu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Zezwól na dostęp do kamery, aby zeskanować kod QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Skanuj kod QR"</string>
<string name="screen_qr_code_login_start_over_button">"Zacznij od nowa"</string>
<string name="screen_qr_code_login_unknown_error_description">"Wystąpił nieoczekiwany błąd. Spróbuj ponownie."</string>
<string name="screen_qr_code_login_verify_code_loading">"Oczekiwanie na drugie urządzenie"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Twój dostawca konta może poprosić o podany kod, aby zweryfikować logowanie."</string>
<string name="screen_qr_code_login_verify_code_title">"Twój kod weryfikacyjny"</string>
<string name="screen_server_confirmation_change_server">"Zmień dostawcę konta"</string> <string name="screen_server_confirmation_change_server">"Zmień dostawcę konta"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Serwer prywatny dla pracowników Element."</string> <string name="screen_server_confirmation_message_login_element_dot_io">"Serwer prywatny dla pracowników Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string>

View file

@ -30,7 +30,48 @@
<string name="screen_login_subtitle">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string> <string name="screen_login_subtitle">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string>
<string name="screen_login_title">"Välkommen tillbaka!"</string> <string name="screen_login_title">"Välkommen tillbaka!"</string>
<string name="screen_login_title_with_homeserver">"Logga in på %1$s"</string> <string name="screen_login_title_with_homeserver">"Logga in på %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Upprättar en säker anslutning"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"En säker anslutning kunde inte göras till den nya enheten. Dina befintliga enheter är fortfarande säkra och du behöver inte oroa dig för dem."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Nu då?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Pröva att logga in igen med en QR-kod ifall detta skulle vara ett nätverksproblem"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Om du stöter på samma problem, prova ett annat wifi-nätverk eller använd din mobildata istället för wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Om det inte fungerar, logga in manuellt"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Anslutningen är inte säker"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Du kommer att bli ombedd att ange de två siffrorna som visas på den här enheten."</string>
<string name="screen_qr_code_login_device_code_title">"Ange numret nedan på din andra enhet"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Logga in på din andra enhet och försök sedan igen, eller använd en annan enhet som redan är inloggad."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Den andra enheten är inte inloggad"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Inloggningen avbröts på den andra enheten."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Inloggningsförfrågan avbröts"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Inloggningen avvisades på den andra enheten."</string>
<string name="screen_qr_code_login_error_declined_title">"Inloggning avvisad"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Inloggningen har löpt ut. Vänligen försök igen."</string>
<string name="screen_qr_code_login_error_expired_title">"Inloggningen slutfördes inte i tid"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Din andra enhet stöder inte inloggning i %s med en QR-kod.
Prova att logga in manuellt eller skanna QR-koden med en annan enhet."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR-kod stöds inte"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Din kontoleverantör stöder inte %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s stöds inte"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Redo att skanna"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Öppna %1$s på en skrivbordsenhet"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klicka på din avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Välj %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"”Länka ny enhet”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Skanna QR-koden med den här enheten"</string>
<string name="screen_qr_code_login_initial_state_title">"Öppna %1$s på en annan enhet för att få QR-koden"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Använd QR-koden som visas på den andra enheten."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Försök igen"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Försök igen"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Fel QR-kod"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Gå till kamerainställningar"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du måste ge tillstånd för %1$s att använda enhetens kamera för att kunna fortsätta."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Tillåt kameraåtkomst för att skanna QR-koden"</string>
<string name="screen_qr_code_login_scanning_state_title">"Skanna QR-koden"</string>
<string name="screen_qr_code_login_start_over_button">"Börja om"</string>
<string name="screen_qr_code_login_unknown_error_description">"Ett oväntat fel inträffade. Vänligen försök igen."</string>
<string name="screen_qr_code_login_verify_code_loading">"Väntar på din andra enhet"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Din kontoleverantör kan be om följande kod för att verifiera inloggningen."</string>
<string name="screen_qr_code_login_verify_code_title">"Din verifieringskod"</string>
<string name="screen_server_confirmation_change_server">"Byt kontoleverantör"</string> <string name="screen_server_confirmation_change_server">"Byt kontoleverantör"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"En privat server för Element-anställda."</string> <string name="screen_server_confirmation_message_login_element_dot_io">"En privat server för Element-anställda."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string>

View file

@ -30,7 +30,48 @@
<string name="screen_login_subtitle">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string> <string name="screen_login_subtitle">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string>
<string name="screen_login_title">"З поверненням!"</string> <string name="screen_login_title">"З поверненням!"</string>
<string name="screen_login_title_with_homeserver">"Увійти в %1$s"</string> <string name="screen_login_title_with_homeserver">"Увійти в %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Встановлення безпечного з\'єднання"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші існуючі пристрої все ще в безпеці, і вам не потрібно про них турбуватися."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Якщо це не спрацює, увійдіть вручну"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"З\'єднання не є безпечним"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вас попросять ввести дві цифри, показані на цьому пристрої."</string>
<string name="screen_qr_code_login_device_code_title">"Введіть номер нижче на іншому пристрої"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Увійдіть на іншому пристрої та спробуйте ще раз або скористайтеся іншим пристроєм, що вже в обліковому записі."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Інший пристрій не ввійшов"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Вхід було скасовано на іншому пристрої."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Запит на вхід скасовано"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Вхід був відхилений на іншому пристрої."</string>
<string name="screen_qr_code_login_error_declined_title">"Вхід відхилено"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Термін входу сплив. Будь ласка, спробуйте ще раз."</string>
<string name="screen_qr_code_login_error_expired_title">"Вхід не було завершено вчасно"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду.
Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR-код не підтримується"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Постачальник вашого облікового запису не підтримує %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s не підтримується"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Готовий до сканування"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Відкрийте %1$s на комп\'ютері"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Натисніть на свою аватарку"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Оберіть %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Підключити новий пристрій”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Відскануйте QR-код цим пристроєм"</string>
<string name="screen_qr_code_login_initial_state_title">"Відкрийте %1$s на іншому пристрої, щоб отримати QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Використовуйте QR-код, показаний на іншому пристрої."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Спробуйте ще раз"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Спробуйте ще раз"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неправильний QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перейти до налаштувань камери"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Надайте доступ до камери, щоб сканувати QR-код"</string>
<string name="screen_qr_code_login_scanning_state_title">"Відскануйте QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Почати спочатку"</string>
<string name="screen_qr_code_login_unknown_error_description">"Сталася несподівана помилка. Будь ласка, спробуйте ще раз."</string>
<string name="screen_qr_code_login_verify_code_loading">"Чекаємо на ваш інший пристрій"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Постачальник облікового запису може попросити вас ввести код нижче для підтвердження входу."</string>
<string name="screen_qr_code_login_verify_code_title">"Ваш код підтвердження"</string>
<string name="screen_server_confirmation_change_server">"Змінити провайдера облікового запису"</string> <string name="screen_server_confirmation_change_server">"Змінити провайдера облікового запису"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Приватний сервер для співробітників Element."</string> <string name="screen_server_confirmation_message_login_element_dot_io">"Приватний сервер для співробітників Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string>

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Hisob provayderini o\'zgartiring"</string>
<string name="screen_account_provider_form_hint">"Uy server manzili"</string>
<string name="screen_account_provider_form_notice">"Qidiruv so\'zini yoki domen manzilini kiriting."</string>
<string name="screen_account_provider_form_subtitle">"Kompaniya, jamoa yoki shaxsiy serverni qidiring."</string>
<string name="screen_account_provider_form_title">"Hisob provayderini toping"</string>
<string name="screen_account_provider_signin_subtitle">"Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."</string>
<string name="screen_account_provider_signin_title">"Siz %sga kirmoqchisiz"</string>
<string name="screen_account_provider_signup_subtitle">"Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."</string>
<string name="screen_account_provider_signup_title">"Siz %sda hisob yaratmoqchisiz"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org - bu Matrix.org Jamg\'armasi tomonidan boshqariladigan xavfsiz, markazlashtirilmagan aloqa uchun ommaviy Matrix tarmog\'idagi katta, bepul server."</string>
<string name="screen_change_account_provider_other">"Boshqa"</string>
<string name="screen_change_account_provider_subtitle">"Shaxsiy serveringiz yoki ishchi hisob qaydnomangiz kabi boshqa hisob provayderidan foydalaning."</string>
<string name="screen_change_account_provider_title">"Hisob provayderini o\'zgartiring"</string>
<string name="screen_change_server_error_invalid_homeserver">"Bu uy serveriga kira olmadik. Iltimos, uy serverining URL manzilini to\'ri kiritganingizni tekshiring. Agar URL toʻgʻri boʻlsa, qoʻshimcha yordam olish uchun uy serveri administratoriga murojaat qiling."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Hozirda bu server siljish sinxronlashni qollab-quvvatlamaydi."</string>
<string name="screen_change_server_form_header">"Uy serverining URL manzili"</string>
<string name="screen_change_server_form_notice">"Siz faqat siljish sinxronlashni qo\'llab-quvvatlaydigan mavjud serverga ulanishingiz mumkin. Uy serveringiz administratori uni sozlashi kerak.%1$s"</string>
<string name="screen_change_server_subtitle">"Serveringizning manzili nima?"</string>
<string name="screen_change_server_title">"Serveringizni tanlang"</string>
<string name="screen_login_error_deactivated_account">"Bu hisob ochirilgan."</string>
<string name="screen_login_error_invalid_credentials">"Notog\'ri foydalanuvchi nomi va/yoki parol"</string>
<string name="screen_login_error_invalid_user_id">"Bu haqiqiy foydalanuvchi identifikatori emas. Kutilayotgan format: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Tanlangan uy serveri parol yoki OIDC loginni qo\'lab-quvvatlamaydi. Iltimos, administratoringizga murojaat qiling yoki boshqa uy serverini tanlang."</string>
<string name="screen_login_form_header">"Tafsilotlaringizni kiriting"</string>
<string name="screen_login_subtitle">"Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."</string>
<string name="screen_login_title">"Qaytib kelganingizdan xursandmiz!"</string>
<string name="screen_login_title_with_homeserver">"Kirish%1$s"</string>
<string name="screen_server_confirmation_change_server">"Hisob provayderini o\'zgartiring"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Element xodimlari uchun shaxsiy server."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir."</string>
<string name="screen_server_confirmation_message_register">"Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."</string>
<string name="screen_server_confirmation_title_login">"Siz tizimga kirmoqchisiz%1$s"</string>
<string name="screen_server_confirmation_title_register">"Hisob yaratmoqchisiz%1$s"</string>
<string name="screen_waitlist_message">"Hozirgi paytda %2$sga %1$sda talab yuqori. Bir necha kundan keyin ilovaga qayting va qaytadan urining.
Sabr-toqatingiz uchun rahmat!"</string>
<string name="screen_waitlist_message_success">"%1$sga Xush kelibsiz!"</string>
<string name="screen_waitlist_title">"Siz deyarli keldingiz."</string>
<string name="screen_waitlist_title_success">"Siz kirdingiz."</string>
</resources>

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.api
/**
* Used to trigger a log out of the current user from any part of the app.
*/
interface LogoutUseCase {
/**
* Log out the current user and then perform any needed cleanup tasks.
* @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway.
* @return the session id of the logged out user.
*/
suspend fun logout(ignoreSdkError: Boolean): String
interface Factory {
fun create(sessionId: String): LogoutUseCase
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.impl
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
class DefaultLogoutUseCase @AssistedInject constructor(
@Assisted private val sessionId: String,
private val matrixClientProvider: MatrixClientProvider,
) : LogoutUseCase {
@ContributesBinding(AppScope::class)
@AssistedFactory
interface Factory : LogoutUseCase.Factory {
override fun create(sessionId: String): DefaultLogoutUseCase
}
override suspend fun logout(ignoreSdkError: Boolean): String {
val matrixClient = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrThrow()
matrixClient.logout(ignoreSdkError = ignoreSdkError)
return sessionId
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.impl
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@Module
@ContributesTo(SessionScope::class)
object SessionLogoutModule {
@Provides
fun provideLogoutUseCase(
currentSessionIdHolder: CurrentSessionIdHolder,
factory: DefaultLogoutUseCase.Factory,
): LogoutUseCase {
return factory.create(currentSessionIdHolder.current.value)
}
}

View file

@ -4,5 +4,9 @@
<string name="screen_signout_confirmation_dialog_submit">"Sair"</string> <string name="screen_signout_confirmation_dialog_submit">"Sair"</string>
<string name="screen_signout_confirmation_dialog_title">"Sair"</string> <string name="screen_signout_confirmation_dialog_title">"Sair"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string> <string name="screen_signout_in_progress_dialog_content">"Saindo…"</string>
<string name="screen_signout_key_backup_disabled_title">"Você desativou o backup"</string>
<string name="screen_signout_key_backup_ongoing_title">"O backup das suas chaves ainda está em andamento"</string>
<string name="screen_signout_preference_item">"Sair"</string> <string name="screen_signout_preference_item">"Sair"</string>
<string name="screen_signout_recovery_disabled_title">"A recuperação não está configurada"</string>
<string name="screen_signout_save_recovery_key_title">"Você salvou sua chave de recuperação?"</string>
</resources> </resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Haqiqatan ham tizimdan chiqmoqchimisiz?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Tizimdan chiqish"</string>
<string name="screen_signout_confirmation_dialog_title">"Tizimdan chiqish"</string>
<string name="screen_signout_in_progress_dialog_content">"Chiqish…"</string>
<string name="screen_signout_preference_item">"Tizimdan chiqish"</string>
</resources>

View file

@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
@ -14,15 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.unlock plugins {
id("io.element.android-library")
import io.element.android.features.lockscreen.impl.unlock.signout.SignOut }
import io.element.android.tests.testutils.simulateLongTask
android {
class FakeSignOut( namespace = "io.element.android.features.logout.test"
var lambda: () -> String? = { null } }
) : SignOut {
override suspend fun invoke(): String? = simulateLongTask { dependencies {
lambda() implementation(libs.coroutines.core)
} implementation(projects.tests.testutils)
api(projects.features.logout.api)
} }

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.test
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLogoutUseCase(
var logoutLambda: (Boolean) -> String = lambdaError()
) : LogoutUseCase {
override suspend fun logout(ignoreSdkError: Boolean): String {
return logoutLambda(ignoreSdkError)
}
}

View file

@ -102,5 +102,6 @@ dependencies {
testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.test)
testImplementation(projects.features.poll.impl) testImplementation(projects.features.poll.impl)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.eventformatter.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -58,7 +58,11 @@ import kotlin.math.roundToInt
* @param modifier The modifier for the layout. * @param modifier The modifier for the layout.
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured. * @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
*/ */
@Suppress("ContentTrailingLambda") @Suppress(
"ContentTrailingLambda",
// False positive
"MultipleEmitters",
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun ExpandableBottomSheetScaffold( internal fun ExpandableBottomSheetScaffold(

View file

@ -81,6 +81,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor( class MessagesFlowNode @AssistedInject constructor(
@ -217,6 +218,10 @@ class MessagesFlowNode @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callType)
} }
override fun onViewAllPinnedEvents() {
Timber.d("On View All Pinned Events not implemented yet.")
}
} }
val inputs = MessagesNode.Inputs( val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId, focusedEventId = inputs.focusedEventId,

View file

@ -97,6 +97,7 @@ class MessagesNode @AssistedInject constructor(
fun onCreatePollClick() fun onCreatePollClick()
fun onEditPollClick(eventId: EventId) fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId) fun onJoinCallClick(roomId: RoomId)
fun onViewAllPinnedEvents()
} }
override fun onBuilt() { override fun onBuilt() {
@ -185,6 +186,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onEditPollClick(eventId) } callbacks.forEach { it.onEditPollClick(eventId) }
} }
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
private fun onSendLocationClick() { private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() } callbacks.forEach { it.onSendLocationClick() }
} }
@ -221,6 +226,7 @@ class MessagesNode @AssistedInject constructor(
onSendLocationClick = this::onSendLocationClick, onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick, onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick, onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
modifier = modifier, modifier = modifier,
) )

View file

@ -20,10 +20,12 @@ import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -39,6 +41,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
@ -73,12 +76,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -98,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter, private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
@ -129,12 +134,12 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToRedactOwn by room.canRedactOwnAsState(updateKey = syncUpdateFlow.value) val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val userHasPermissionToRedactOther by room.canRedactOtherAsState(updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val roomName: AsyncData<String> by remember { val roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
} }
@ -211,11 +216,8 @@ class MessagesPresenter @AssistedInject constructor(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = heroes, heroes = heroes,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState, composerState = composerState,
userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState, timelineState = timelineState,
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
@ -231,10 +233,24 @@ class MessagesPresenter @AssistedInject constructor(
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
callState = callState, callState = callState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
value = UserEventPermissions(
canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true },
canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true },
canRedactOwn = room.canRedactOwn().getOrElse { false },
canRedactOther = room.canRedactOther().getOrElse { false },
canPinUnpin = room.canPinUnpin().getOrElse { false },
)
}
}
private fun MatrixRoomInfo.avatarData(): AvatarData { private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData( return AvatarData(
id = id.value, id = id.value,
@ -268,6 +284,30 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Forward -> handleForwardAction(targetEvent) TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
}
}
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
timelineController.invokeOnCurrentTimeline {
pinEvent(targetEvent.eventId)
.onFailure {
Timber.e(it, "Failed to pin event ${targetEvent.eventId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
timelineController.invokeOnCurrentTimeline {
unpinEvent(targetEvent.eventId)
.onFailure {
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
} }
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@ -37,10 +38,7 @@ data class MessagesState(
val roomName: AsyncData<String>, val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>, val roomAvatar: AsyncData<AvatarData>,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val userHasPermissionToSendMessage: Boolean, val userEventPermissions: UserEventPermissions,
val userHasPermissionToRedactOwn: Boolean,
val userHasPermissionToRedactOther: Boolean,
val userHasPermissionToSendReaction: Boolean,
val composerState: MessageComposerState, val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
@ -57,6 +55,7 @@ data class MessagesState(
val enableVoiceMessages: Boolean, val enableVoiceMessages: Boolean,
val callState: RoomCallState, val callState: RoomCallState,
val appName: String, val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )

View file

@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
@ -53,7 +55,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(), aMessagesState(),
aMessagesState(hasNetworkConnection = false), aMessagesState(hasNetworkConnection = false),
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)), aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
aMessagesState(userHasPermissionToSendMessage = false), aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true), aMessagesState(showReinvitePrompt = true),
aMessagesState( aMessagesState(
roomName = AsyncData.Uninitialized, roomName = AsyncData.Uninitialized,
@ -87,16 +89,19 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState( aMessagesState(
callState = RoomCallState.DISABLED, callState = RoomCallState.DISABLED,
), ),
aMessagesState(
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
knownPinnedMessagesCount = 4,
currentPinnedMessageIndex = 0,
),
),
) )
} }
fun aMessagesState( fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"), roomName: AsyncData<String> = AsyncData.Success("Room name"),
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage: Boolean = true, userEventPermissions: UserEventPermissions = aUserEventPermissions(),
userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState( composerState: MessageComposerState = aMessageComposerState(
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
isFullScreen = false, isFullScreen = false,
@ -116,16 +121,14 @@ fun aMessagesState(
showReinvitePrompt: Boolean = false, showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true, enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED, callState: RoomCallState = RoomCallState.ENABLED,
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {}, eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState( ) = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = persistentListOf(), heroes = persistentListOf(),
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userEventPermissions = userEventPermissions,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState, composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
@ -142,9 +145,24 @@ fun aMessagesState(
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
callState = callState, callState = callState,
appName = "Element", appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink, eventSink = eventSink,
) )
fun aUserEventPermissions(
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,
canSendMessage: Boolean = true,
canSendReaction: Boolean = true,
canPinUnpin: Boolean = false,
) = UserEventPermissions(
canRedactOwn = canRedactOwn,
canRedactOther = canRedactOther,
canSendMessage = canSendMessage,
canSendReaction = canSendReaction,
canPinUnpin = canPinUnpin,
)
fun aReactionSummaryState( fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null, target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {} eventSink: (ReactionSummaryEvents) -> Unit = {}

View file

@ -16,6 +16,9 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -68,6 +71,11 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsBott
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -102,11 +110,13 @@ import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
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.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun MessagesView( fun MessagesView(
@ -120,8 +130,9 @@ fun MessagesView(
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false forceJumpToBottomVisibility: Boolean = false,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
@ -154,10 +165,7 @@ fun MessagesView(
state.actionListState.eventSink( state.actionListState.eventSink(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = event, event = event,
canRedactOwn = state.userHasPermissionToRedactOwn, userEventPermissions = state.userEventPermissions,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction,
) )
) )
} }
@ -225,6 +233,7 @@ fun MessagesView(
}, },
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
) )
}, },
snackbarHost = { snackbarHost = {
@ -316,6 +325,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
forceJumpToBottomVisibility: Boolean, forceJumpToBottomVisibility: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
@ -373,22 +383,41 @@ private fun MessagesViewContent(
RectangleShape RectangleShape
}, },
content = { paddingValues -> content = { paddingValues ->
TimelineView( Box(modifier = Modifier.padding(paddingValues)) {
state = state.timelineState, val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
typingNotificationState = state.typingNotificationState, TimelineView(
onUserDataClick = onUserDataClick, state = state.timelineState,
onLinkClick = onLinkClick, typingNotificationState = state.typingNotificationState,
onMessageClick = onMessageClick, onUserDataClick = onUserDataClick,
onMessageLongClick = onMessageLongClick, onLinkClick = onLinkClick,
onSwipeToReply = onSwipeToReply, onMessageClick = onMessageClick,
onReactionClick = onReactionClick, onMessageLongClick = onMessageLongClick,
onReactionLongClick = onReactionLongClick, onSwipeToReply = onSwipeToReply,
onMoreReactionsClick = onMoreReactionsClick, onReactionClick = onReactionClick,
onReadReceiptClick = onReadReceiptClick, onReactionLongClick = onReactionLongClick,
modifier = Modifier.padding(paddingValues), onMoreReactionsClick = onMoreReactionsClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
) onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
}
}, },
sheetContent = { subcomposing: Boolean -> sheetContent = { subcomposing: Boolean ->
MessagesViewComposerBottomSheetContents( MessagesViewComposerBottomSheetContents(
@ -408,7 +437,7 @@ private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean, subcomposing: Boolean,
state: MessagesState, state: MessagesState,
) { ) {
if (state.userHasPermissionToSendMessage) { if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
MentionSuggestionsPickerView( MentionSuggestionsPickerView(
modifier = Modifier modifier = Modifier
@ -557,12 +586,13 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventClick = { false }, onEventClick = { false },
onPreviewAttachments = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {}, onSendLocationClick = {},
onCreatePollClick = {}, onCreatePollClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
} }

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl
/**
* Represents the permissions a user has in a room.
* It's dependent of the user's power level in the room.
*/
data class UserEventPermissions(
val canRedactOwn: Boolean,
val canRedactOther: Boolean,
val canSendMessage: Boolean,
val canSendReaction: Boolean,
val canPinUnpin: Boolean,
) {
companion object {
val DEFAULT = UserEventPermissions(
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = false
)
}
}

View file

@ -16,15 +16,13 @@
package io.element.android.features.messages.impl.actionlist package io.element.android.features.messages.impl.actionlist
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents { sealed interface ActionListEvents {
data object Clear : ActionListEvents data object Clear : ActionListEvents
data class ComputeForMessage( data class ComputeForMessage(
val event: TimelineItem.Event, val event: TimelineItem.Event,
val canRedactOwn: Boolean, val userEventPermissions: UserEventPermissions,
val canRedactOther: Boolean,
val canSendMessage: Boolean,
val canSendReaction: Boolean,
) : ActionListEvents ) : ActionListEvents
} }

View file

@ -23,25 +23,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
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.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ActionListPresenter @Inject constructor( class ActionListPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val room: MatrixRoom,
) : Presenter<ActionListState> { ) : Presenter<ActionListState> {
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
@ -52,17 +63,20 @@ class ActionListPresenter @Inject constructor(
} }
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds }
}.collectAsState(initial = persistentListOf())
fun handleEvents(event: ActionListEvents) { fun handleEvents(event: ActionListEvents) {
when (event) { when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event, timelineItem = event.event,
userCanRedactOwn = event.canRedactOwn, usersEventPermissions = event.userEventPermissions,
userCanRedactOther = event.canRedactOther,
userCanSendMessage = event.canSendMessage,
userCanSendReaction = event.canSendReaction,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isPinnedEventsEnabled = isPinnedEventsEnabled,
pinnedEventIds = pinnedEventIds,
target = target, target = target,
) )
} }
@ -76,136 +90,22 @@ class ActionListPresenter @Inject constructor(
private fun CoroutineScope.computeForMessage( private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
userCanRedactOwn: Boolean, usersEventPermissions: UserEventPermissions,
userCanRedactOther: Boolean,
userCanSendMessage: Boolean,
userCanSendReaction: Boolean,
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
isPinnedEventsEnabled: Boolean,
pinnedEventIds: ImmutableList<EventId>,
target: MutableState<ActionListState.Target> target: MutableState<ActionListState.Target>
) = launch { ) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val canRedact = timelineItem.isMine && userCanRedactOwn || !timelineItem.isMine && userCanRedactOther
val actions = val actions = buildActions(
when (timelineItem.content) { timelineItem = timelineItem,
is TimelineItemCallNotifyContent -> { usersEventPermissions = usersEventPermissions,
if (isDeveloperModeEnabled) { isDeveloperModeEnabled = isDeveloperModeEnabled,
listOf(TimelineItemAction.ViewSource) isPinnedEventsEnabled = isPinnedEventsEnabled,
} else { isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
emptyList() )
} val displayEmojiReactions = usersEventPermissions.canSendReaction &&
}
is TimelineItemRedactedContent -> {
if (isDeveloperModeEnabled) {
listOf(TimelineItemAction.ViewSource)
} else {
emptyList()
}
}
is TimelineItemStateContent -> {
buildList {
add(TimelineItemAction.Copy)
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
}
}
is TimelineItemPollContent -> {
val canEndPoll = timelineItem.isRemote &&
!timelineItem.content.isEnded &&
(timelineItem.isMine || canRedact)
buildList {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
add(TimelineItemAction.Reply)
}
if (timelineItem.isRemote && timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (canEndPoll) {
add(TimelineItemAction.EndPoll)
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
}
}
}
is TimelineItemVoiceContent -> {
buildList {
if (timelineItem.isRemote) {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
}
}
}
is TimelineItemLegacyCallInviteContent -> {
buildList {
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
}
}
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
if (userCanSendMessage) {
if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread)
} else {
add(TimelineItemAction.Reply)
}
}
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
if (!timelineItem.isSticker) {
add(TimelineItemAction.Forward)
}
}
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
}
}
}
val displayEmojiReactions = userCanSendReaction &&
timelineItem.isRemote && timelineItem.isRemote &&
timelineItem.content.canReact() timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) { if (actions.isNotEmpty() || displayEmojiReactions) {
@ -219,3 +119,71 @@ class ActionListPresenter @Inject constructor(
} }
} }
} }
private fun buildActions(
timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
isPinnedEventsEnabled: Boolean,
isEventPinned: Boolean,
): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildList {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread)
} else {
add(TimelineItemAction.Reply)
}
}
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)
}
val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote
if (canPinUnpin) {
if (isEventPinned) {
add(TimelineItemAction.Unpin)
} else {
add(TimelineItemAction.Pin)
}
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
}
}.postFilter(timelineItem.content)
}
/**
* Post filter the actions based on the content of the event.
*/
private fun List<TimelineItemAction>.postFilter(content: TimelineItemEventContent): List<TimelineItemAction> {
return filter { action ->
when (content) {
is TimelineItemCallNotifyContent,
is TimelineItemLegacyCallInviteContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> {
action == TimelineItemAction.ViewSource
}
else -> true
}
}
}

View file

@ -218,6 +218,7 @@ private fun SheetContent(
} }
} }
@Suppress("MultipleEmitters") // False positive
@Composable @Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) { private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
val content: @Composable () -> Unit val content: @Composable () -> Unit

View file

@ -39,4 +39,8 @@ sealed class TimelineItemAction(
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options) 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 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 EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
// TODO use the Unpin compound icon when available.
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_pin)
} }

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class)
@Module
interface MessagesModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
fun interface IsPinnedMessagesFeatureEnabled {
@Composable
operator fun invoke(): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor(
private val featureFlagService: FeatureFlagService,
) : IsPinnedMessagesFeatureEnabled {
@Composable
override operator fun invoke(): Boolean {
var isFeatureEnabled by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(Unit) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents)
.onEach { isFeatureEnabled = it }
.launchIn(this)
}
return isFeatureEnabled
}
}

View file

@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
@ -14,8 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.unlock.signout package io.element.android.features.messages.impl.pinned.banner
interface SignOut { sealed interface PinnedMessagesBannerEvents {
suspend operator fun invoke(): String? data object MoveToNextPinned : PinnedMessagesBannerEvents
} }

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.matrix.api.core.EventId
data class PinnedMessagesBannerItem(
val eventId: EventId,
val formatted: AnnotatedString,
)

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.coroutines.withContext
import javax.inject.Inject
class PinnedMessagesBannerItemFactory @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val formatter: PinnedMessagesBannerFormatter,
) {
suspend fun create(timelineItem: MatrixTimelineItem): PinnedMessagesBannerItem? = withContext(coroutineDispatchers.computation) {
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null
val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
formatted = if (formatted is AnnotatedString) {
formatted
} else {
AnnotatedString(formatted.toString())
},
)
}
else -> null
}
}
}

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
class PinnedMessagesBannerPresenter @Inject constructor(
private val room: MatrixRoom,
private val itemFactory: PinnedMessagesBannerItemFactory,
private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val networkMonitor: NetworkMonitor,
) : Presenter<PinnedMessagesBannerState> {
private val pinnedItems = mutableStateOf<ImmutableList<PinnedMessagesBannerItem>>(persistentListOf())
@Composable
override fun present(): PinnedMessagesBannerState {
val isFeatureEnabled = isFeatureEnabled()
val expectedPinnedMessagesCount by remember {
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
}.collectAsState(initial = 0)
var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) }
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
PinnedMessagesBannerItemsEffect(
isFeatureEnabled = isFeatureEnabled,
onItemsChange = { newItems ->
val pinnedMessageCount = newItems.size
if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
currentPinnedMessageIndex = pinnedMessageCount - 1
}
pinnedItems.value = newItems
},
onTimelineFail = { hasTimelineFailed ->
hasTimelineFailedToLoad = hasTimelineFailed
}
)
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size)
}
}
}
return pinnedMessagesBannerState(
isFeatureEnabled = isFeatureEnabled,
hasTimelineFailed = hasTimelineFailedToLoad,
expectedPinnedMessagesCount = expectedPinnedMessagesCount,
pinnedItems = pinnedItems.value,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)
}
@Composable
private fun pinnedMessagesBannerState(
isFeatureEnabled: Boolean,
hasTimelineFailed: Boolean,
expectedPinnedMessagesCount: Int,
pinnedItems: ImmutableList<PinnedMessagesBannerItem>,
currentPinnedMessageIndex: Int,
eventSink: (PinnedMessagesBannerEvents) -> Unit
): PinnedMessagesBannerState {
val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
return when {
!isFeatureEnabled -> PinnedMessagesBannerState.Hidden
hasTimelineFailed -> PinnedMessagesBannerState.Hidden
currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
loadedPinnedMessagesCount = pinnedItems.size,
eventSink = eventSink
)
expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PinnedMessagesBannerItemsEffect(
isFeatureEnabled: Boolean,
onItemsChange: (ImmutableList<PinnedMessagesBannerItem>) -> Unit,
onTimelineFail: (Boolean) -> Unit,
) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
val networkStatus by networkMonitor.connectivity.collectAsState()
LaunchedEffect(isFeatureEnabled, networkStatus) {
if (!isFeatureEnabled) {
updatedOnItemsChange(persistentListOf())
return@LaunchedEffect
}
val pinnedEventsTimeline = room.pinnedEventsTimeline()
.onFailure { updatedOnTimelineFail(true) }
.onSuccess { updatedOnTimelineFail(false) }
.getOrNull()
?: return@LaunchedEffect
pinnedEventsTimeline.timelineItems
.debounce(300.milliseconds)
.map { timelineItems ->
timelineItems.mapNotNull { timelineItem ->
itemFactory.create(timelineItem)
}.toImmutableList()
}
.onEach { newItems ->
updatedOnItemsChange(newItems)
}
.onCompletion {
pinnedEventsTimeline.close()
}
.launchIn(this)
}
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed interface PinnedMessagesBannerState {
data object Hidden : PinnedMessagesBannerState
sealed interface Visible : PinnedMessagesBannerState
data class Loading(val expectedPinnedMessagesCount: Int) : Visible
data class Loaded(
val currentPinnedMessage: PinnedMessagesBannerItem,
val currentPinnedMessageIndex: Int,
val loadedPinnedMessagesCount: Int,
val eventSink: (PinnedMessagesBannerEvents) -> Unit
) : Visible
fun pinnedMessagesCount() = when (this) {
is Hidden -> 0
is Loading -> expectedPinnedMessagesCount
is Loaded -> loadedPinnedMessagesCount
}
fun currentPinnedMessageIndex() = when (this) {
is Hidden -> 0
is Loading -> expectedPinnedMessagesCount - 1
is Loaded -> currentPinnedMessageIndex
}
@Composable
fun formattedMessage() = when (this) {
is Hidden -> AnnotatedString("")
is Loading -> stringResource(id = CommonStrings.screen_room_pinned_banner_loading_description).toAnnotatedString()
is Loaded -> currentPinnedMessage.formatted
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import kotlin.random.Random
internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> {
override val values: Sequence<PinnedMessagesBannerState>
get() = sequenceOf(
aHiddenPinnedMessagesBannerState(),
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1),
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 4),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 2, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 1),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 2),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 3),
)
}
internal fun aHiddenPinnedMessagesBannerState() = PinnedMessagesBannerState.Hidden
internal fun aLoadingPinnedMessagesBannerState(
knownPinnedMessagesCount: Int = 4
) = PinnedMessagesBannerState.Loading(
expectedPinnedMessagesCount = knownPinnedMessagesCount
)
internal fun aLoadedPinnedMessagesBannerState(
currentPinnedMessageIndex: Int = 0,
knownPinnedMessagesCount: Int = 1,
currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
eventId = EventId("\$" + Random.nextInt().toString()),
formatted = AnnotatedString("This is a pinned message")
),
eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
loadedPinnedMessagesCount = knownPinnedMessagesCount,
eventSink = eventSink
)

View file

@ -0,0 +1,289 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
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.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
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.pinnedMessageBannerBorder
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinnedMessagesBannerView(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit,
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (state) {
PinnedMessagesBannerState.Hidden -> Unit
is PinnedMessagesBannerState.Visible -> {
PinnedMessagesBannerRow(
state = state,
onClick = onClick,
onViewAllClick = onViewAllClick,
)
}
}
}
}
@Composable
private fun PinnedMessagesBannerRow(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit,
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBorder(borderColor)
.heightIn(min = 64.dp)
.clickable {
if (state is PinnedMessagesBannerState.Loaded) {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) {
Spacer(modifier = Modifier.width(16.dp))
PinIndicators(
pinIndex = state.currentPinnedMessageIndex(),
pinsCount = state.pinnedMessagesCount(),
modifier = Modifier.heightIn(max = 40.dp)
)
Icon(
imageVector = CompoundIcons.PinSolid(),
contentDescription = null,
tint = ElementTheme.materialColors.secondary,
modifier = Modifier.size(20.dp)
)
PinnedMessageItem(
index = state.currentPinnedMessageIndex(),
totalCount = state.pinnedMessagesCount(),
message = state.formattedMessage(),
modifier = Modifier.weight(1f)
)
ViewAllButton(state, onViewAllClick)
}
}
@Composable
private fun ViewAllButton(
state: PinnedMessagesBannerState,
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val text = if (state is PinnedMessagesBannerState.Loaded) {
stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title)
} else {
""
}
TextButton(
text = text,
showProgress = state is PinnedMessagesBannerState.Loading,
onClick = onViewAllClick
)
}
}
private fun Modifier.drawBorder(borderColor: Color): Modifier {
return this
.drawBehind {
val strokeWidth = 0.5.dp.toPx()
val y = size.height - strokeWidth / 2
drawLine(
borderColor,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
drawLine(
borderColor,
Offset(0f, 0f),
Offset(size.width, 0f),
strokeWidth
)
}
.shadow(elevation = 5.dp, spotColor = Color.Transparent)
}
@Composable
private fun PinIndicators(
pinIndex: Int,
pinsCount: Int,
modifier: Modifier = Modifier,
) {
val indicatorHeight = remember(pinsCount) {
when (pinsCount) {
0 -> 0
1 -> 32
2 -> 18
else -> 11
}
}
val lazyListState = rememberLazyListState()
LaunchedEffect(pinIndex) {
val viewportSize = lazyListState.layoutInfo.viewportSize
lazyListState.animateScrollToItem(
pinIndex,
indicatorHeight / 2 - viewportSize.height / 2
)
}
LazyColumn(
modifier = modifier,
state = lazyListState,
verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false,
) {
items(pinsCount) { index ->
Box(
modifier = Modifier
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
)
}
}
}
@Composable
private fun PinnedMessageItem(
index: Int,
totalCount: Int,
message: AnnotatedString?,
modifier: Modifier = Modifier,
) {
val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount)
val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage)
Column(modifier = modifier) {
AnimatedVisibility(totalCount > 1) {
Text(
text = annotatedTextWithBold(
text = fullCountMessage,
boldText = countMessage,
),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (message != null) {
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
@Stable
internal interface PinnedMessagesBannerViewScrollBehavior {
val isVisible: Boolean
val nestedScrollConnection: NestedScrollConnection
}
internal object PinnedMessagesBannerViewDefaults {
@Composable
fun rememberExitOnScrollBehavior(): PinnedMessagesBannerViewScrollBehavior = remember {
ExitOnScrollBehavior()
}
}
private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior {
override var isVisible by mutableStateOf(true)
override val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) {
isVisible = true
}
if (available.y > 1) {
isVisible = false
}
return Offset.Zero
}
}
}
@PreviewsDayNight
@Composable
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state,
onClick = {},
onViewAllClick = {},
)
}

View file

@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlin.time.Duration
sealed interface TimelineEvents { sealed interface TimelineEvents {
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
data class FocusOnEvent(val eventId: EventId) : TimelineEvents data class FocusOnEvent(val eventId: EventId, val debounce: Duration = Duration.ZERO) : TimelineEvents
data object ClearFocusRequestState : TimelineEvents data object ClearFocusRequestState : TimelineEvents
data object OnFocusEventRender : TimelineEvents data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents data object JumpToLive : TimelineEvents

View file

@ -50,12 +50,15 @@ import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L
class TimelinePresenter @AssistedInject constructor( class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemIndexer: TimelineItemIndexer, private val timelineItemIndexer: TimelineItemIndexer,
@ -136,13 +139,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.EditPoll -> { is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId) navigator.onEditPollClick(event.pollStartId)
} }
is TimelineEvents.FocusOnEvent -> localScope.launch { is TimelineEvents.FocusOnEvent -> {
if (timelineItemIndexer.isKnown(event.eventId)) { focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
val index = timelineItemIndexer.indexOf(event.eventId)
focusRequestState.value = FocusRequestState.Success(eventId = event.eventId, index = index)
} else {
focusRequestState.value = FocusRequestState.Loading(eventId = event.eventId)
}
} }
is TimelineEvents.OnFocusEventRender -> { is TimelineEvents.OnFocusEventRender -> {
focusRequestState.value = focusRequestState.value.onFocusEventRender() focusRequestState.value = focusRequestState.value.onFocusEventRender()
@ -157,18 +155,29 @@ class TimelinePresenter @AssistedInject constructor(
} }
LaunchedEffect(focusRequestState.value) { LaunchedEffect(focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value when (val currentFocusRequestState = focusRequestState.value) {
if (currentFocusRequestState is FocusRequestState.Loading) { is FocusRequestState.Requested -> {
val eventId = currentFocusRequestState.eventId delay(currentFocusRequestState.debounce)
timelineController.focusOnEvent(eventId) if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
.fold( val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
onSuccess = { focusRequestState.value = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
focusRequestState.value = FocusRequestState.Success(eventId = eventId) } else {
}, focusRequestState.value = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
onFailure = { }
focusRequestState.value = FocusRequestState.Failure(throwable = it) }
} is FocusRequestState.Loading -> {
) val eventId = currentFocusRequestState.eventId
timelineController.focusOnEvent(eventId)
.fold(
onSuccess = {
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
},
onFailure = {
focusRequestState.value = FocusRequestState.Failure(throwable = it)
}
)
}
else -> Unit
} }
} }

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@Immutable @Immutable
data class TimelineState( data class TimelineState(
@ -39,6 +40,7 @@ data class TimelineState(
@Immutable @Immutable
sealed interface FocusRequestState { sealed interface FocusRequestState {
data object None : FocusRequestState data object None : FocusRequestState
data class Requested(val eventId: EventId, val debounce: Duration) : FocusRequestState
data class Loading(val eventId: EventId) : FocusRequestState data class Loading(val eventId: EventId) : FocusRequestState
data class Success( data class Success(
val eventId: EventId, val eventId: EventId,
@ -54,6 +56,7 @@ sealed interface FocusRequestState {
fun eventId(): EventId? { fun eventId(): EventId? {
return when (this) { return when (this) {
is Requested -> eventId
is Loading -> eventId is Loading -> eventId
is Success -> eventId is Success -> eventId
else -> null else -> null

View file

@ -128,6 +128,7 @@ internal fun aTimelineItemEvent(
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
isMine: Boolean = false, isMine: Boolean = false,
isEditable: Boolean = false, isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender", senderDisplayName: String = "Sender",
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
@ -152,6 +153,7 @@ internal fun aTimelineItemEvent(
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
senderProfile = aProfileTimelineDetailsReady( senderProfile = aProfileTimelineDetailsReady(
displayName = senderDisplayName, displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,

View file

@ -48,7 +48,10 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -90,7 +93,9 @@ fun TimelineView(
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
) { ) {
fun clearFocusRequestState() { fun clearFocusRequestState() {
state.eventSink(TimelineEvents.ClearFocusRequestState) state.eventSink(TimelineEvents.ClearFocusRequestState)
@ -109,7 +114,6 @@ fun TimelineView(
} }
val context = LocalContext.current val context = LocalContext.current
val lazyListState = rememberLazyListState()
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember { val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java) val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
@ -124,7 +128,9 @@ fun TimelineView(
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
state = lazyListState, state = lazyListState,
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),

View file

@ -82,7 +82,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aGreyShiel
import io.element.android.features.messages.impl.timeline.model.event.aRedShield import io.element.android.features.messages.impl.timeline.model.event.aRedShield
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -153,7 +152,7 @@ fun TimelineItemEventRow(
} else { } else {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
} }
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.content.canBeRepliedTo() val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
if (canReply) { if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState() val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.floatValue val offset = state.offset.floatValue
@ -410,6 +409,7 @@ private fun MessageSenderInformation(
} }
} }
@Suppress("MultipleEmitters") // False positive
@Composable @Composable
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,

View file

@ -110,7 +110,7 @@ fun TimelineItemReactionsLayout(
} }
val rows = rowsIn.toMutableList() val rows = rowsIn.toMutableList()
val secondLastRow = rows[rows.size - 2].toMutableList() val secondLastRow = rows[rows.size - 2].toMutableList()
val expandButtonPlaceable = secondLastRow.removeLast() val expandButtonPlaceable = secondLastRow.removeAt(secondLastRow.lastIndex)
lastRow.add(0, expandButtonPlaceable) lastRow.add(0, expandButtonPlaceable)
rows[rows.size - 2] = secondLastRow rows[rows.size - 2] = secondLastRow
rows[rows.size - 1] = lastRow rows[rows.size - 1] = lastRow

View file

@ -76,6 +76,7 @@ class TimelineItemEventFactory @Inject constructor(
content = contentFactory.create(currentTimelineItem.event), content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn, isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable, isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTime = sentTime, sentTime = sentTime,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),

View file

@ -75,6 +75,7 @@ sealed interface TimelineItem {
val sentTime: String = "", val sentTime: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts, val readReceiptState: TimelineItemReadReceipts,

View file

@ -24,27 +24,27 @@ sealed interface TimelineItemEventContent {
} }
/** /**
* Only text based content and states can be copied. * Only text based content can be copied.
*/ */
fun TimelineItemEventContent.canBeCopied(): Boolean = fun TimelineItemEventContent.canBeCopied(): Boolean =
when (this) { this is TimelineItemTextBasedContent
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> true
else -> false
}
/** /**
* Determine if the event content can be replied to. * Returns true if the event content can be forwarded.
* Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
*/ */
fun TimelineItemEventContent.canBeRepliedTo(): Boolean = fun TimelineItemEventContent.canBeForwarded(): Boolean =
when (this) { when (this) {
is TimelineItemRedactedContent, is TimelineItemTextBasedContent,
is TimelineItemLegacyCallInviteContent, is TimelineItemImageContent,
is TimelineItemCallNotifyContent, is TimelineItemFileContent,
is TimelineItemStateContent -> false is TimelineItemAudioContent,
else -> true is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent -> true
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
else -> false
} }
/** /**

View file

@ -33,11 +33,12 @@ internal fun MessagesViewWithTypingPreview(
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventClick = { false }, onEventClick = { false },
onPreviewAttachments = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {}, onSendLocationClick = {},
onCreatePollClick = {}, onCreatePollClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
) )
} }

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Suppress("MultipleEmitters") // False positive
@Composable @Composable
fun TypingNotificationView( fun TypingNotificationView(
state: TypingNotificationState, state: TypingNotificationState,

View file

@ -39,12 +39,18 @@
<string name="screen_room_timeline_read_marker_title">"Nowe"</string> <string name="screen_room_timeline_read_marker_title">"Nowe"</string>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d zmiana pokoju"</item> <item quantity="one">"%1$d zmiana pokoju"</item>
<item quantity="few">"%1$d zmian pokoju"</item> <item quantity="few">"%1$d zmiany pokoju"</item>
<item quantity="many">"%1$d zmiany pokoju"</item> <item quantity="many">"%1$d zmian pokoju"</item>
</plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s i %3$d inny"</item>
<item quantity="few">"%1$s, %2$s i %3$d innych"</item>
<item quantity="many">"%1$s, %2$s i %3$d innych"</item>
</plurals> </plurals>
<plurals name="screen_room_typing_notification"> <plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s piszę"</item> <item quantity="one">"%1$s piszę"</item>
<item quantity="few">"%1$s piszą"</item> <item quantity="few">"%1$s piszą"</item>
<item quantity="many">"%1$s piszą"</item> <item quantity="many">"%1$s pisze"</item>
</plurals> </plurals>
<string name="screen_room_typing_two_members">"%1$s i %2$s"</string>
</resources> </resources>

View file

@ -23,6 +23,7 @@
<string name="screen_room_encrypted_history_banner">"O histórico de mensagens não está disponível no momento."</string> <string name="screen_room_encrypted_history_banner">"O histórico de mensagens não está disponível no momento."</string>
<string name="screen_room_invite_again_alert_message">"Gostaria de convidá-los de volta?"</string> <string name="screen_room_invite_again_alert_message">"Gostaria de convidá-los de volta?"</string>
<string name="screen_room_invite_again_alert_title">"Você está sozinho neste chat"</string> <string name="screen_room_invite_again_alert_title">"Você está sozinho neste chat"</string>
<string name="screen_room_mentions_at_room_subtitle">"Notificar a sala inteira"</string>
<string name="screen_room_mentions_at_room_title">"Todos"</string> <string name="screen_room_mentions_at_room_title">"Todos"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Enviar novamente"</string> <string name="screen_room_retry_send_menu_send_again_action">"Enviar novamente"</string>
<string name="screen_room_retry_send_menu_title">"Sua mensagem não foi enviada"</string> <string name="screen_room_retry_send_menu_title">"Sua mensagem não foi enviada"</string>
@ -36,7 +37,16 @@
<string name="screen_room_timeline_reactions_show_more">"Mostrar mais"</string> <string name="screen_room_timeline_reactions_show_more">"Mostrar mais"</string>
<string name="screen_room_timeline_read_marker_title">"Novo"</string> <string name="screen_room_timeline_read_marker_title">"Novo"</string>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d mudança de sala"</item> <item quantity="one">"%1$d alteração na sala"</item>
<item quantity="other">"%1$d mudanças de salas"</item> <item quantity="other">"%1$d alterações na sala"</item>
</plurals> </plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s e %3$d outro"</item>
<item quantity="other">"%1$s, %2$s e %3$d outros"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s está digitando"</item>
<item quantity="other">"%1$s estão digitando"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s e %2$s"</string>
</resources> </resources>

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="emoji_picker_category_activity">"Faoliyatlar"</string>
<string name="emoji_picker_category_flags">"Bayroqlar"</string>
<string name="emoji_picker_category_foods">"Oziq-ovqat va ichimliklar"</string>
<string name="emoji_picker_category_nature">"Hayvonlar va tabiat"</string>
<string name="emoji_picker_category_objects">"Ob\'ektlar"</string>
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</string>
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>
<string name="screen_report_content_hint">"Ushbu kontent haqida xabar berish sababi"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Rasmga olmoq"</string>
<string name="screen_room_attachment_source_camera_video">"Video yozib olish"</string>
<string name="screen_room_attachment_source_files">"Biriktirma"</string>
<string name="screen_room_attachment_source_gallery">"Fotosurat va video kutubxonasi"</string>
<string name="screen_room_attachment_source_location">"Joylashuv"</string>
<string name="screen_room_attachment_source_poll">"So\'ro\'vnoma"</string>
<string name="screen_room_attachment_text_formatting">"Matnni formatlash"</string>
<string name="screen_room_encrypted_history_banner">"Xabarlar tarixi hozirda mavjud emas."</string>
<string name="screen_room_invite_again_alert_message">"Ularni yana taklif qilmoqchimisiz?"</string>
<string name="screen_room_invite_again_alert_title">"Siz bu chatda yolg\'izsiz"</string>
<string name="screen_room_mentions_at_room_title">"Har kim"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Yana yuboring"</string>
<string name="screen_room_retry_send_menu_title">"Xabaringiz yuborilmadi"</string>
<string name="screen_room_timeline_add_reaction">"Emoji qo\'shmoq"</string>
<string name="screen_room_timeline_beginning_of_room">"Bu %1$sni boshlanishi"</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Bu suhbatning boshlanishi."</string>
<string name="screen_room_timeline_less_reactions">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_message_copied">"Xabar nusxalandi"</string>
<string name="screen_room_timeline_no_permission_to_post">"Sizda bu xonaga post yozishga ruxsat yoq"</string>
<string name="screen_room_timeline_reactions_show_less">"Kamroq ko\'rsatish"</string>
<string name="screen_room_timeline_reactions_show_more">"Ko\'proq ko\'rsatish"</string>
<string name="screen_room_timeline_read_marker_title">"Yangi"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dxonani almashtirish"</item>
<item quantity="other">"%1$dxona o\'zgarishi"</item>
</plurals>
</resources>

View file

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
@ -43,6 +44,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
@ -77,6 +79,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -137,8 +140,8 @@ class MessagesPresenterTest {
assertThat(initialState.roomName).isEqualTo(AsyncData.Success("")) assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomAvatar) assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))) .isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue() assertThat(initialState.userEventPermissions.canSendMessage).isTrue()
assertThat(initialState.userHasPermissionToRedactOwn).isTrue() assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue() assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
@ -155,6 +158,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
assertThat(room.markAsReadCalls).isEmpty() assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
@ -175,6 +179,7 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true)) givenRoomInfo(aRoomInfo(hasRoomCall = true))
} }
@ -203,6 +208,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -240,6 +246,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -298,6 +305,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
@ -487,6 +495,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) } val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
@ -561,6 +570,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -596,6 +606,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -620,6 +631,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -644,6 +656,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
@ -679,6 +692,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Error( MatrixRoomMembersState.Error(
@ -715,6 +729,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
room.givenRoomMembersState(MatrixRoomMembersState.Unknown) room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
@ -741,6 +756,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
@ -781,13 +797,14 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = awaitFirstItem() val state = awaitFirstItem()
assertThat(state.userHasPermissionToSendMessage).isTrue() assertThat(state.userEventPermissions.canSendMessage).isTrue()
} }
} }
@ -805,15 +822,16 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Default value // Default value
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue()
skipItems(1) skipItems(1)
assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -826,14 +844,15 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(false) }, canRedactOtherResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOwn }.last() val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last()
assertThat(initialState.userHasPermissionToRedactOwn).isTrue() assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.userHasPermissionToRedactOther).isFalse() assertThat(initialState.userEventPermissions.canRedactOther).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -846,14 +865,15 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(false) }, canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
) )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOther }.last() val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last()
assertThat(initialState.userHasPermissionToRedactOwn).isFalse() assertThat(initialState.userEventPermissions.canRedactOwn).isFalse()
assertThat(initialState.userHasPermissionToRedactOther).isTrue() assertThat(initialState.userEventPermissions.canRedactOther).isTrue()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -878,6 +898,74 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - handle action pin`() = runTest {
val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
val timeline = FakeTimeline()
val room = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
val initialState = awaitFirstItem()
timeline.pinEventLambda = successPinEventLambda
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent))
assert(successPinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
timeline.pinEventLambda = failurePinEventLambda
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent))
assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
assertThat(awaitItem().snackbarMessage).isNotNull()
}
}
@Test
fun `present - handle action unpin`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
val timeline = FakeTimeline()
val room = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
val initialState = awaitFirstItem()
timeline.unpinEventLambda = successUnpinEventLambda
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent))
assert(successUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
timeline.unpinEventLambda = failureUnpinEventLambda
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent))
assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId))
assertThat(awaitItem().snackbarMessage).isNotNull()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
// Skip 2 item if Mentions feature is enabled, else 1 // Skip 2 item if Mentions feature is enabled, else 1
skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1) skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1)
@ -892,6 +980,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = "")) givenRoomInfo(aRoomInfo(id = roomId, name = ""))
}, },
@ -958,14 +1047,21 @@ class MessagesPresenterTest {
return timelinePresenter return timelinePresenter
} }
} }
val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore) val featureFlagService = FakeFeatureFlagService()
val actionListPresenter = ActionListPresenter(
appPreferencesStore = appPreferencesStore,
featureFlagsService = featureFlagService,
room = matrixRoom,
)
val typingNotificationPresenter = TypingNotificationPresenter( val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom, room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
@ -976,11 +1072,12 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter, readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator, navigator = navigator,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
featureFlagsService = FakeFeatureFlagService(), featureFlagsService = featureFlagService,
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),

View file

@ -26,12 +26,14 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory import io.element.android.emojibasebindings.EmojibaseCategory
@ -42,8 +44,11 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
@ -53,8 +58,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureCalledOnceWithParam
@ -73,6 +78,7 @@ import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MessagesViewTest { class MessagesViewTest {
@ -169,16 +175,20 @@ class MessagesViewTest {
userHasPermissionToRedactOwn: Boolean = false, userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false, userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false, userHasPermissionToSendReaction: Boolean = false,
userCanPinEvent: Boolean = false,
) { ) {
val eventsRecorder = EventsRecorder<ActionListEvents>() val eventsRecorder = EventsRecorder<ActionListEvents>()
val state = aMessagesState( val state = aMessagesState(
actionListState = anActionListState( actionListState = anActionListState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userEventPermissions = UserEventPermissions(
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, canSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOther = userHasPermissionToRedactOther, canRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, canRedactOther = userHasPermissionToRedactOther,
canSendReaction = userHasPermissionToSendReaction,
canPinUnpin = userCanPinEvent,
),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( rule.setMessagesView(
@ -189,10 +199,7 @@ class MessagesViewTest {
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = timelineItem, event = timelineItem,
canRedactOwn = state.userHasPermissionToRedactOwn, userEventPermissions = state.userEventPermissions,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction,
) )
) )
} }
@ -237,9 +244,11 @@ class MessagesViewTest {
private fun swipeTest(userHasPermissionToSendMessage: Boolean) { private fun swipeTest(userHasPermissionToSendMessage: Boolean) {
val eventsRecorder = EventsRecorder<MessagesEvents>() val eventsRecorder = EventsRecorder<MessagesEvents>()
val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true)
val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false)
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()), timelineItems = persistentListOf(canBeRepliedEvent, cannotBeRepliedEvent),
timelineRoomInfo = aTimelineRoomInfo( timelineRoomInfo = aTimelineRoomInfo(
userHasPermissionToSendMessage = userHasPermissionToSendMessage userHasPermissionToSendMessage = userHasPermissionToSendMessage
), ),
@ -249,10 +258,12 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { swipeRight(endX = 200f) } rule.onAllNodesWithTag(TestTags.messageBubble.value).apply {
onFirst().performTouchInput { swipeRight(endX = 200f) }
onLast().performTouchInput { swipeRight(endX = 200f) }
}
if (userHasPermissionToSendMessage) { if (userHasPermissionToSendMessage) {
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, canBeRepliedEvent))
eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, timelineItem))
} else { } else {
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
@ -454,6 +465,25 @@ class MessagesViewTest {
customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet) customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!)) eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!))
} }
@Test
fun `clicking on pinned messages banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
val state = aMessagesState(
timelineState = aTimelineState(eventSink = eventsRecorder),
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
knownPinnedMessagesCount = 2,
currentPinnedMessageIndex = 0,
currentPinnedMessage = PinnedMessagesBannerItem(
eventId = AN_EVENT_ID,
formatted = AnnotatedString("This is a pinned message")
),
),
)
rule.setMessagesView(state = state)
rule.onNodeWithText("This is a pinned message").performClick()
eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView(
@ -467,6 +497,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onSendLocationClick: () -> Unit = EnsureNeverCalled(), onSendLocationClick: () -> Unit = EnsureNeverCalled(),
onCreatePollClick: () -> Unit = EnsureNeverCalled(), onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
@ -484,6 +515,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onSendLocationClick = onSendLocationClick, onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick, onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
) )
} }
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.aUserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@ -31,7 +32,13 @@ 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.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
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
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -46,7 +53,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -57,7 +64,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from me redacted`() = runTest { fun `present - compute for message from me redacted`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -66,10 +73,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -91,7 +101,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from others redacted`() = runTest { fun `present - compute for message from others redacted`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -104,10 +114,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -129,7 +142,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message`() = runTest { fun `present - compute for others message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -142,10 +155,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -158,6 +174,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -172,7 +189,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message cannot sent message`() = runTest { fun `present - compute for others message cannot sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -185,10 +202,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = false, canRedactOther = false,
canSendReaction = true canSendMessage = false,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -200,6 +220,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -214,7 +235,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message and can redact`() = runTest { fun `present - compute for others message and can redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -227,10 +248,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = true, canRedactOwn = false,
canSendMessage = true, canRedactOther = true,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -241,6 +265,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -256,7 +281,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message and cannot send reaction`() = runTest { fun `present - compute for others message and cannot send reaction`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -269,10 +294,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = true, canRedactOwn = false,
canSendMessage = true, canRedactOther = true,
canSendReaction = false canSendMessage = true,
canSendReaction = false,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -283,6 +311,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -298,7 +327,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message`() = runTest { fun `present - compute for my message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -310,10 +339,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -327,6 +359,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -341,7 +374,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message cannot redact`() = runTest { fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -353,10 +386,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -370,6 +406,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -383,7 +420,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a media item`() = runTest { fun `present - compute for a media item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -396,10 +433,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -412,6 +452,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
@ -425,7 +466,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a state item in debug build`() = runTest { fun `present - compute for a state item in debug build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -437,10 +478,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = stateEvent, event = stateEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -451,8 +495,6 @@ class ActionListPresenterTest {
event = stateEvent, event = stateEvent,
displayEmojiReactions = false, displayEmojiReactions = false,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) )
) )
@ -464,7 +506,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a state item in non-debuggable build`() = runTest { fun `present - compute for a state item in non-debuggable build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -476,33 +518,24 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = stateEvent, event = stateEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = stateEvent,
displayEmojiReactions = false,
actions = persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
} }
} }
@Test @Test
fun `present - compute message in non-debuggable build`() = runTest { fun `present - compute message in non-debuggable build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -514,10 +547,59 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message when user can't pin`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = false,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -533,6 +615,61 @@ class ActionListPresenterTest {
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message when event is already pinned`() = runTest {
val room = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createActionListPresenter(
isDeveloperModeEnabled = true,
isPinFeatureEnabled = true,
room = room
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Unpin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -544,7 +681,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute message with no actions`() = runTest { fun `present - compute message with no actions`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -561,10 +698,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
@ -572,10 +712,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = redactedEvent, event = redactedEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
)
) )
) )
awaitItem().run { awaitItem().run {
@ -586,7 +728,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute not sent message`() = runTest { fun `present - compute not sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -595,16 +737,20 @@ class ActionListPresenterTest {
// No event id, so it's not sent yet // No event id, so it's not sent yet
eventId = null, eventId = null,
isMine = true, isMine = true,
canBeRepliedTo = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -624,7 +770,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for editable poll message`() = runTest { fun `present - compute for editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -637,10 +783,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -652,6 +801,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -662,7 +812,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for non-editable poll message`() = runTest { fun `present - compute for non-editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -675,10 +825,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -689,6 +842,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -699,7 +853,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for ended poll message`() = runTest { fun `present - compute for ended poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -712,10 +866,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -725,6 +882,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -735,22 +893,26 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for voice message`() = runTest { fun `present - compute for voice message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
isEditable = false,
content = aTimelineItemVoiceContent(), content = aTimelineItemVoiceContent(),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -761,6 +923,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -771,7 +934,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for call notify`() = runTest { fun `present - compute for call notify`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -783,10 +946,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -803,7 +968,20 @@ class ActionListPresenterTest {
} }
} }
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter { private fun createActionListPresenter(
isDeveloperModeEnabled: Boolean,
isPinFeatureEnabled: Boolean,
room: MatrixRoom = FakeMatrixRoom(),
): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return ActionListPresenter(appPreferencesStore = preferencesStore) val featureFlagsService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.PinnedEvents.key to isPinFeatureEnabled,
)
)
return ActionListPresenter(
appPreferencesStore = preferencesStore,
featureFlagsService = featureFlagsService,
room = room
)
} }

View file

@ -42,6 +42,7 @@ internal fun aMessageEvent(
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
isMine: Boolean = true, isMine: Boolean = true,
isEditable: Boolean = true, isEditable: Boolean = true,
canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
@ -58,6 +59,7 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState, localSendState = sendState,

View file

@ -0,0 +1,203 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesBannerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - feature disabled`() = runTest {
val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = false)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
}
}
@Test
fun `present - loading state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
}
}
@Test
fun `present - loaded state`() = runTest {
val messageContent = aMessageContent("A message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
)
)
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString())
}
}
@Test
fun `present - loaded state - multiple pinned messages`() = runTest {
val messageContent1 = aMessageContent("A message")
val messageContent2 = aMessageContent("Another message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent1,
),
),
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID_2",
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = messageContent2,
),
)
)
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent1.toString())
loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
}
}
}
@Test
fun `present - timeline failed`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(Exception()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
awaitItem().also { loadingState ->
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
}
awaitItem().also { failedState ->
assertThat(failedState).isEqualTo(PinnedMessagesBannerState.Hidden)
}
}
}
private fun TestScope.createPinnedMessagesBannerPresenter(
room: MatrixRoom = FakeMatrixRoom(),
itemFactory: PinnedMessagesBannerItemFactory = PinnedMessagesBannerItemFactory(
coroutineDispatchers = testCoroutineDispatchers(),
formatter = FakePinnedMessagesBannerFormatter(
formatLambda = { event -> "${event.content}" }
)
),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
isFeatureEnabled: Boolean = true,
): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter(
room = room,
itemFactory = itemFactory,
isFeatureEnabled = { isFeatureEnabled },
networkMonitor = networkMonitor,
)
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
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.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PinnedMessagesBannerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on the banner invoke expected callback`() {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvents>()
val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder
)
val pinnedEventId = state.currentPinnedMessage.eventId
ensureCalledOnceWithParam(pinnedEventId) { callback ->
rule.setPinnedMessagesBannerView(
state = state,
onClick = callback
)
rule.onRoot().performClick()
eventsRecorder.assertSingle(PinnedMessagesBannerEvents.MoveToNextPinned)
}
}
@Test
fun `clicking on view all emit the expected event`() {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvents>(expectEvents = true)
val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setPinnedMessagesBannerView(
state = state,
onViewAllClick = callback
)
rule.clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesBannerView(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onViewAllClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PinnedMessagesBannerView(
state = state,
onClick = onClick,
onViewAllClick = onViewAllClick
)
}
}

View file

@ -75,6 +75,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Date import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
@ -496,6 +497,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
@ -541,6 +546,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0)) assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0))
@ -564,6 +573,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))

View file

@ -46,6 +46,7 @@ class TimelineItemGrouperTest {
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
isEditable = false, isEditable = false,
canBeRepliedTo = false,
inReplyTo = null, inReplyTo = null,
isThreaded = false, isThreaded = false,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),

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