Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-02 15:29:06 +02:00
commit d55d5a801b
182 changed files with 2492 additions and 884 deletions

View file

@ -34,7 +34,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/gradle-build-action@v2 uses: gradle/gradle-build-action@v2.4.2
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

@ -16,7 +16,7 @@ jobs:
maestro-cloud: maestro-cloud:
name: Maestro test suite name: Maestro test suite
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.review.state == 'approved' if: github.event.review.state == 'approved' || github.event_name == 'workflow_dispatch'
strategy: strategy:
fail-fast: false fail-fast: false
# Allow one per PR. # Allow one per PR.

View file

@ -31,7 +31,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/gradle-build-action@v2 uses: gradle/gradle-build-action@v2.4.2
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite - name: Run code quality check suite
@ -79,7 +79,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/gradle-build-action@v2 uses: gradle/gradle-build-action@v2.4.2
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

@ -34,7 +34,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/gradle-build-action@v2 uses: gradle/gradle-build-action@v2.4.2
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -12,22 +12,10 @@ jobs:
if: > if: >
github.repository == 'vector-im/element-x-android' github.repository == 'vector-im/element-x-android'
steps: steps:
- uses: octokit/graphql-action@v2.x - uses: actions/add-to-project@main
with: with:
headers: '{"GraphQL-Features": "projects_next_graphql"}' project-url: https://github.com/orgs/vector-im/projects/43
query: | github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
mutation add_to_project($projectid:ID!,$contentid:ID!) {
addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
item {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PVT_kwDOAM0swc4ABTXY"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
ex_plorers: ex_plorers:
name: Add labelled issues to X-Plorer project name: Add labelled issues to X-Plorer project
@ -35,23 +23,10 @@ jobs:
if: > if: >
contains(github.event.issue.labels.*.name, 'Team: Element X Feature') contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
steps: steps:
- uses: octokit/graphql-action@v2.x - uses: actions/add-to-project@main
id: add_to_project
with: with:
headers: '{"GraphQL-Features": "projects_next_graphql"}' project-url: https://github.com/orgs/vector-im/projects/73
query: | github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
mutation add_to_project($projectid:ID!,$contentid:ID!) {
addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
item {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PVT_kwDOAM0swc4ALoFY"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
verticals_feature: verticals_feature:
name: Add labelled issues to Verticals Feature project name: Add labelled issues to Verticals Feature project
@ -59,20 +34,18 @@ jobs:
if: > if: >
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
steps: steps:
- uses: octokit/graphql-action@v2.x - uses: actions/add-to-project@main
id: add_to_project
with: with:
headers: '{"GraphQL-Features": "projects_next_graphql"}' project-url: https://github.com/orgs/vector-im/projects/57
query: | github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
mutation add_to_project($projectid:ID!,$contentid:ID!) {
addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { qa:
item { name: Add labelled issues to QA project
id runs-on: ubuntu-latest
} if: >
} contains(github.event.issue.labels.*.name, 'Team: QA')
} steps:
projectid: ${{ env.PROJECT_ID }} - uses: actions/add-to-project@main
contentid: ${{ github.event.issue.node_id }} with:
env: project-url: https://github.com/orgs/vector-im/projects/69
PROJECT_ID: "PVT_kwDOAM0swc4AHJKW" github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

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.8.10" /> <option name="version" value="1.8.20" />
</component> </component>
</project> </project>

View file

@ -1,6 +1,7 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- tapOn: "Settings" - tapOn:
id: "home_screen-settings"
- tapOn: "Sign out" - tapOn: "Sign out"
- takeScreenshot: build/maestro/900-SignOutDialg - takeScreenshot: build/maestro/900-SignOutDialg
# Ensure cancel cancels # Ensure cancel cancels

View file

@ -1,6 +1,7 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- tapOn: "Settings" - tapOn:
id: "home_screen-settings"
- assertVisible: "Rageshake to report bug" - assertVisible: "Rageshake to report bug"
- takeScreenshot: build/maestro/600-Settings - takeScreenshot: build/maestro/600-Settings
- tapOn: - tapOn:

View file

@ -23,7 +23,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
api(libs.anvil.compiler.api) api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils) implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.13.0") implementation("com.squareup:kotlinpoet:1.13.1")
implementation(libs.dagger) implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1") kapt("com.google.auto.service:auto-service:1.0.1")

View file

@ -28,6 +28,16 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ElementX" android:theme="@style/Theme.ElementX"
tools:targetApi="33"> tools:targetApi="33">
<provider
android:authorities="${applicationId}.fileprovider"
android:name="androidx.core.content.FileProvider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_providers" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"

View file

@ -19,8 +19,8 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.x.di.AppComponent import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.MatrixInitializer import io.element.android.x.initializer.MatrixInitializer

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2023 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.
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
</paths>

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
@ -41,7 +42,6 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -102,7 +102,7 @@ class RoomFlowNode @AssistedInject constructor(
private fun fetchRoomMembers() = lifecycleScope.launch { private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room val room = inputs.room
room.fetchMembers() room.updateMembers()
.onFailure { .onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}") Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess { }.onSuccess {
@ -134,8 +134,18 @@ class RoomFlowNode @AssistedInject constructor(
object RoomDetails : NavTarget object RoomDetails : NavTarget
} }
private val timeline = inputs.room.timeline()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
DisposableEffect(Unit) {
timeline.initialize()
onDispose {
timeline.dispose()
}
}
Children( Children(
navModel = backstack, navModel = backstack,
modifier = modifier, modifier = modifier,

View file

@ -24,7 +24,6 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.nodeTestHelper
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -81,19 +80,6 @@ class RoomFlowNodeTest {
roomMembershipObserver = RoomMembershipObserver() roomMembershipObserver = RoomMembershipObserver()
) )
@Test
fun `given a room flow node when initialized then it fetches room members`() {
// GIVEN
val room = FakeMatrixRoom()
val inputs = RoomFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs))
Truth.assertThat(room.areMembersFetched).isFalse()
// WHEN
roomFlowNode.nodeTestHelper()
// THEN
Truth.assertThat(room.areMembersFetched).isTrue()
}
@Test @Test
fun `given a room flow node when initialized then it loads messages entry point`() { fun `given a room flow node when initialized then it loads messages entry point`() {
// GIVEN // GIVEN

1
changelog.d/339.feature Normal file
View file

@ -0,0 +1 @@
Block & unblock users from room details screen.

1
changelog.d/354.feature Normal file
View file

@ -0,0 +1 @@
Improve room list search and general UI

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_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
</resources>

View file

@ -3,6 +3,16 @@
<string name="screen_create_room_action_create_room">"Cameră nouă"</string> <string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_action_invite_people">"Invitați persoane"</string> <string name="screen_create_room_action_invite_people">"Invitați persoane"</string>
<string name="screen_create_room_add_people_title">"Adaugați persoane"</string> <string name="screen_create_room_add_people_title">"Adaugați persoane"</string>
<string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string>
<string name="screen_create_room_private_option_description">"Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior."</string>
<string name="screen_create_room_private_option_title">"Cameră privată (doar pe bază de invitație)"</string>
<string name="screen_create_room_public_option_description">"Mesajele nu sunt criptate și oricine le poate citi. Puteți activa criptarea la o dată ulterioară."</string>
<string name="screen_create_room_public_option_title">"Cameră publică (oricine)"</string>
<string name="screen_create_room_room_name_label">"Numele camerei"</string>
<string name="screen_create_room_room_name_placeholder">"e.g. Mici și Cozonaci"</string>
<string name="screen_create_room_title">"Creați o cameră"</string>
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
<string name="screen_create_room_topic_placeholder">"Despre ce este această cameră?"</string>
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string> <string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_start_chat_unknown_profile">"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."</string> <string name="screen_start_chat_unknown_profile">"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."</string>
</resources> </resources>

View file

@ -102,7 +102,7 @@ class CreateRoomRootPresenterTests {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain")) val matrixUser = MatrixUser(UserId("@name:domain"))
val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult:domain")) val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
fakeMatrixClient.givenFindDmResult(fakeDmResult) fakeMatrixClient.givenFindDmResult(fakeDmResult)

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="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s hat dich eingeladen"</string>
</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_invites_invited_you">"%1$s te invitó."</string>
</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_invites_invited_you">"%1$s ti ha invitato"</string>
</resources>

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">"Sigur doriți să refuzați alăturarea la %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Refuzați invitația"</string>
<string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Refuzați conversația"</string>
<string name="screen_invites_empty_list">"Nicio invitație"</string>
<string name="screen_invites_invited_you">"%1$s v-a invitat"</string>
</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_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_password_hint">"Passwort"</string>
<string name="screen_login_username_hint">"Benutzername"</string>
</resources>

View file

@ -4,17 +4,17 @@
<string name="screen_change_server_error_no_sliding_sync_message">"Este servidor no soporta sliding sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Este servidor no soporta sliding sync."</string>
<string name="screen_change_server_form_header">"Dirección del homeserver"</string> <string name="screen_change_server_form_header">"Dirección del homeserver"</string>
<string name="screen_change_server_form_notice">"Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s"</string> <string name="screen_change_server_form_notice">"Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s"</string>
<string name="screen_change_server_submit">"Continuar"</string>
<string name="screen_change_server_subtitle">"¿Cuál es la dirección de tu servidor?"</string> <string name="screen_change_server_subtitle">"¿Cuál es la dirección de tu servidor?"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
<string name="screen_login_error_deactivated_account">"Esta cuenta ha sido desactivada."</string> <string name="screen_login_error_deactivated_account">"Esta cuenta ha sido desactivada."</string>
<string name="screen_login_error_invalid_credentials">"Usuario y/o contraseña incorrectos"</string> <string name="screen_login_error_invalid_credentials">"Usuario y/o contraseña incorrectos"</string>
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string> <string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
<string name="screen_login_form_header">"Introduce tus datos"</string> <string name="screen_login_form_header">"Introduce tus datos"</string>
<string name="screen_login_password_hint">"Contraseña"</string>
<string name="screen_login_server_header">"Donde viven tus conversaciones"</string> <string name="screen_login_server_header">"Donde viven tus conversaciones"</string>
<string name="screen_login_submit">"Continuar"</string>
<string name="screen_login_title">"¡Hola de nuevo!"</string> <string name="screen_login_title">"¡Hola de nuevo!"</string>
<string name="screen_change_server_submit">"Continuar"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
<string name="screen_login_password_hint">"Contraseña"</string>
<string name="screen_login_submit">"Continuar"</string>
<string name="screen_login_username_hint">"Usuario"</string> <string name="screen_login_username_hint">"Usuario"</string>
</resources> </resources>

View file

@ -4,17 +4,17 @@
<string name="screen_change_server_error_no_sliding_sync_message">"Questo server attualmente non supporta la sincronizzazione scorrevole."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Questo server attualmente non supporta la sincronizzazione scorrevole."</string>
<string name="screen_change_server_form_header">"URL dell\'homeserver"</string> <string name="screen_change_server_form_header">"URL dell\'homeserver"</string>
<string name="screen_change_server_form_notice">"Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s"</string> <string name="screen_change_server_form_notice">"Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s"</string>
<string name="screen_change_server_submit">"Continua"</string>
<string name="screen_change_server_subtitle">"Qual è l\'indirizzo del tuo server?"</string> <string name="screen_change_server_subtitle">"Qual è l\'indirizzo del tuo server?"</string>
<string name="screen_change_server_title">"Seleziona il tuo server"</string>
<string name="screen_login_error_deactivated_account">"Questo profilo è stato disattivato."</string> <string name="screen_login_error_deactivated_account">"Questo profilo è stato disattivato."</string>
<string name="screen_login_error_invalid_credentials">"Nome utente e/o password errati"</string> <string name="screen_login_error_invalid_credentials">"Nome utente e/o password errati"</string>
<string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string> <string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string>
<string name="screen_login_form_header">"Inserisci i tuoi dati"</string> <string name="screen_login_form_header">"Inserisci i tuoi dati"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_server_header">"Dove vivono le tue conversazioni"</string> <string name="screen_login_server_header">"Dove vivono le tue conversazioni"</string>
<string name="screen_login_submit">"Continua"</string>
<string name="screen_login_title">"Bentornato!"</string> <string name="screen_login_title">"Bentornato!"</string>
<string name="screen_change_server_submit">"Continua"</string>
<string name="screen_change_server_title">"Seleziona il tuo server"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continua"</string>
<string name="screen_login_username_hint">"Nome utente"</string> <string name="screen_login_username_hint">"Nome utente"</string>
</resources> </resources>

View file

@ -4,17 +4,17 @@
<string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string>
<string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string> <string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string>
<string name="screen_change_server_form_notice">"Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s"</string> <string name="screen_change_server_form_notice">"Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s"</string>
<string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string> <string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string>
<string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_error_deactivated_account">"Acest cont a fost dezactivat."</string> <string name="screen_login_error_deactivated_account">"Acest cont a fost dezactivat."</string>
<string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string> <string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string>
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string> <string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
<string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string> <string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string>
<string name="screen_login_form_header">"Introduceți detaliile"</string> <string name="screen_login_form_header">"Introduceți detaliile"</string>
<string name="screen_login_password_hint">"Parolă"</string>
<string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string> <string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string>
<string name="screen_login_submit">"Continuați"</string>
<string name="screen_login_title">"Bine ați revenit!"</string> <string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</string>
<string name="screen_login_submit">"Continuați"</string>
<string name="screen_login_username_hint">"Utilizator"</string> <string name="screen_login_username_hint">"Utilizator"</string>
</resources> </resources>

View file

@ -4,17 +4,17 @@
<string name="screen_change_server_error_no_sliding_sync_message">"This server currently doesnt support sliding sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"This server currently doesnt support sliding sync."</string>
<string name="screen_change_server_form_header">"Homeserver URL"</string> <string name="screen_change_server_form_header">"Homeserver URL"</string>
<string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string> <string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string>
<string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_subtitle">"What is the address of your server?"</string> <string name="screen_change_server_subtitle">"What is the address of your server?"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_error_deactivated_account">"This account has been deactivated."</string> <string name="screen_login_error_deactivated_account">"This account has been deactivated."</string>
<string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string> <string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string>
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: @user:homeserver.org"</string> <string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: @user:homeserver.org"</string>
<string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string> <string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string>
<string name="screen_login_form_header">"Enter your details"</string> <string name="screen_login_form_header">"Enter your details"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_server_header">"Where your conversations live"</string> <string name="screen_login_server_header">"Where your conversations live"</string>
<string name="screen_login_submit">"Continue"</string>
<string name="screen_login_title">"Welcome back!"</string> <string name="screen_login_title">"Welcome back!"</string>
<string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continue"</string>
<string name="screen_login_username_hint">"Username"</string> <string name="screen_login_username_hint">"Username"</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_signout_confirmation_dialog_submit">"Abmelden"</string>
<string name="screen_signout_confirmation_dialog_title">"Abmelden"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmeldung läuft…"</string>
<string name="screen_signout_preference_item">"Abmelden"</string>
</resources>

View file

@ -40,6 +40,8 @@ dependencies {
implementation(projects.libraries.textcomposer) implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.mediapickers)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)
@ -55,6 +57,8 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.featureflag.test)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -1,38 +0,0 @@
/*
* Copyright (c) 2022 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.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.element.android.features.messages.test", appContext.packageName)
}
}

View file

@ -102,6 +102,7 @@ private fun SheetContent(
// Crashes if sheetContent size is zero // Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp)) Box(modifier = modifier.size(1.dp))
} }
is ActionListState.Target.Success -> { is ActionListState.Target.Success -> {
val actions = target.actions val actions = target.actions
LazyColumn( LazyColumn(
@ -146,5 +147,11 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st
@Composable @Composable
private fun ContentToPreview(state: ActionListState) { private fun ContentToPreview(state: ActionListState) {
SheetContent(state) ActionListView(
state = state,
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
onActionSelected = { _, _ -> }
)
} }

View file

@ -24,4 +24,6 @@ sealed interface MessageComposerEvents {
object CloseSpecialMode : MessageComposerEvents object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: CharSequence) : MessageComposerEvents data class UpdateText(val text: CharSequence) : MessageComposerEvents
object TakePhoto : MessageComposerEvents
} }

View file

@ -21,23 +21,37 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
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.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class MessageComposerPresenter @Inject constructor( class MessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val room: MatrixRoom private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@Composable @Composable
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
// Example usage of custom pickers
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
Timber.d("Photo saved at $uri")
})
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
@ -63,9 +77,14 @@ class MessageComposerPresenter @Inject constructor(
text.value = "".toStableCharSequence() text.value = "".toStableCharSequence()
composerMode.setToNormal() composerMode.setToNormal()
} }
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
} MessageComposerEvents.TakePhoto -> localCoroutineScope.launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
cameraPhotoPicker.launch()
}
}}
} }
return MessageComposerState( return MessageComposerState(
@ -92,6 +111,7 @@ class MessageComposerPresenter @Inject constructor(
capturedMode.eventId, capturedMode.eventId,
text text
) )
is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage( is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId, capturedMode.eventId,

View file

@ -53,6 +53,9 @@ fun MessageComposerView(
composerMode = state.mode, composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode, onCloseSpecialMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange, onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.TakePhoto)
},
composerCanSendMessage = state.isSendButtonVisible, composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(), composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight, isInDarkMode = !ElementTheme.colors.isLight,

View file

@ -80,13 +80,6 @@ class TimelinePresenter @Inject constructor(
.launchIn(this) .launchIn(this)
} }
DisposableEffect(Unit) {
timeline.initialize()
onDispose {
timeline.dispose()
}
}
return TimelineState( return TimelineState(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value, paginationState = paginationState.value,

View file

@ -99,11 +99,11 @@ fun TimelineView(
itemsIndexed( itemsIndexed(
items = state.timelineItems, items = state.timelineItems,
contentType = { _, timelineItem -> timelineItem.contentType() }, contentType = { _, timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.key() }, key = { _, timelineItem -> timelineItem.identifier() },
) { index, timelineItem -> ) { index, timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value, isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked onLongClick = onMessageLongClicked
) )
@ -121,21 +121,6 @@ fun TimelineView(
} }
} }
private fun TimelineItem.key(): String {
return when (this) {
is TimelineItem.Event -> id
is TimelineItem.Virtual -> id
}
}
private fun TimelineItem.contentType(): Int {
// Todo optimize for each subtype
return when (this) {
is TimelineItem.Event -> 0
is TimelineItem.Virtual -> 1
}
}
@Composable @Composable
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,

View file

@ -25,7 +25,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
ListUpdateCallback { ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) { override fun onChanged(position: Int, count: Int, payload: Any?) {
Timber.v("onChanged(position= $position, count= $count") Timber.d("onChanged(position= $position, count= $count)")
(position until position + count).forEach { (position until position + count).forEach {
// Invalidate cache // Invalidate cache
itemStatesCache[it] = null itemStatesCache[it] = null
@ -33,13 +33,13 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
} }
override fun onMoved(fromPosition: Int, toPosition: Int) { override fun onMoved(fromPosition: Int, toPosition: Int) {
Timber.v("onMoved(fromPosition= $fromPosition, toPosition= $toPosition") Timber.d("onMoved(fromPosition= $fromPosition, toPosition= $toPosition)")
val model = itemStatesCache.removeAt(fromPosition) val model = itemStatesCache.removeAt(fromPosition)
itemStatesCache.add(toPosition, model) itemStatesCache.add(toPosition, model)
} }
override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
Timber.v("onInserted(position= $position, count= $count") Timber.d("onInserted(position= $position, count= $count)")
itemStatesCache.invalidateLast() itemStatesCache.invalidateLast()
repeat(count) { repeat(count) {
itemStatesCache.add(position, null) itemStatesCache.add(position, null)
@ -47,7 +47,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
} }
override fun onRemoved(position: Int, count: Int) { override fun onRemoved(position: Int, count: Int) {
Timber.v("onRemoved(position= $position, count= $count") Timber.d("onRemoved(position= $position, count= $count)")
itemStatesCache.invalidateLast() itemStatesCache.invalidateLast()
repeat(count) { repeat(count) {
itemStatesCache.removeAt(position) itemStatesCache.removeAt(position)

View file

@ -31,6 +31,11 @@ sealed interface TimelineItem {
is Virtual -> id is Virtual -> id
} }
fun contentType(): String = when (this) {
is Event -> content.type
is Virtual -> model.type
}
@Immutable @Immutable
data class Virtual( data class Virtual(
val id: String, val id: String,

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent( data class TimelineItemEmoteContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
}

View file

@ -20,4 +20,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
data class TimelineItemEncryptedContent( data class TimelineItemEncryptedContent(
val data: UnableToDecryptContent.Data val data: UnableToDecryptContent.Data
) : TimelineItemEventContent ) : TimelineItemEventContent {
override val type: String = "TimelineItemEncryptedContent"
}

View file

@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface TimelineItemEventContent sealed interface TimelineItemEventContent {
val type: String
}

View file

@ -23,4 +23,6 @@ data class TimelineItemImageContent(
val mediaRequestData: MediaRequestData, val mediaRequestData: MediaRequestData,
val blurhash: String?, val blurhash: String?,
val aspectRatio: Float val aspectRatio: Float
) : TimelineItemEventContent ) : TimelineItemEventContent{
override val type: String = "TimelineItemImageContent"
}

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent( data class TimelineItemNoticeContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
object TimelineItemRedactedContent : TimelineItemEventContent object TimelineItemRedactedContent : TimelineItemEventContent{
override val type: String = "TimelineItemRedactedContent"
}

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent( data class TimelineItemTextContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent{
override val type: String = "TimelineItemTextContent"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
object TimelineItemUnknownContent : TimelineItemEventContent object TimelineItemUnknownContent : TimelineItemEventContent {
override val type: String = "TimelineItemUnknownContent"
}

View file

@ -18,4 +18,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual
data class TimelineItemDaySeparatorModel( data class TimelineItemDaySeparatorModel(
val formattedDate: String val formattedDate: String
) : TimelineItemVirtualModel ) : TimelineItemVirtualModel {
override val type: String = "TimelineItemDaySeparatorModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemLoadingModel : TimelineItemVirtualModel object TimelineItemLoadingModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemLoadingModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemReadMarkerModel : TimelineItemVirtualModel object TimelineItemReadMarkerModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemReadMarkerModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemTimelineStartModel : TimelineItemVirtualModel object TimelineItemTimelineStartModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemTimelineStartModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemUnknownVirtualModel"
}

View file

@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface TimelineItemVirtualModel sealed interface TimelineItemVirtualModel {
val type: String
}

View file

@ -31,10 +31,12 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -129,9 +131,10 @@ class MessagesPresenterTest {
): MessagesPresenter { ): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
room = matrixRoom room = matrixRoom,
mediaPickerProvider = PickerProvider(isInTest = true),
featureFlagService = FakeFeatureFlagService(),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.fixtures package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@ -31,6 +33,8 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
internal fun aTimelineItemsFactory() = TimelineItemsFactory( internal fun aTimelineItemsFactory() = TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),

View file

@ -27,23 +27,37 @@ import io.element.android.features.messages.impl.textcomposer.MessageComposerEve
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class MessageComposerPresenterTest { class MessageComposerPresenterTest {
private val pickerProvider = PickerProvider(isInTest = true)
private val featureFlagService = FakeFeatureFlagService().apply {
runBlocking {
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
}
}
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -60,7 +74,9 @@ class MessageComposerPresenterTest {
fun `present - toggle fullscreen`() = runTest { fun `present - toggle fullscreen`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -79,7 +95,9 @@ class MessageComposerPresenterTest {
fun `present - change message`() = runTest { fun `present - change message`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -100,7 +118,9 @@ class MessageComposerPresenterTest {
fun `present - change mode to edit`() = runTest { fun `present - change mode to edit`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -130,7 +150,9 @@ class MessageComposerPresenterTest {
fun `present - change mode to reply`() = runTest { fun `present - change mode to reply`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -150,7 +172,9 @@ class MessageComposerPresenterTest {
fun `present - change mode to quote`() = runTest { fun `present - change mode to quote`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -170,7 +194,9 @@ class MessageComposerPresenterTest {
fun `present - send message`() = runTest { fun `present - send message`() = runTest {
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
FakeMatrixRoom() FakeMatrixRoom(),
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -192,7 +218,9 @@ class MessageComposerPresenterTest {
val fakeMatrixRoom = FakeMatrixRoom() val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
fakeMatrixRoom fakeMatrixRoom,
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -223,7 +251,9 @@ class MessageComposerPresenterTest {
val fakeMatrixRoom = FakeMatrixRoom() val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter( val presenter = MessageComposerPresenter(
this, this,
fakeMatrixRoom fakeMatrixRoom,
pickerProvider,
featureFlagService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -248,6 +278,25 @@ class MessageComposerPresenterTest {
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
} }
} }
@Test
fun `present - Take photo`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.TakePhoto)
// TODO verify some post processing of the captured image is done
}
}
} }
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)

View file

@ -49,23 +49,6 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - makes sure timeline is initialized and disposed`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(matrixTimeline = fakeTimeline),
)
assertThat(fakeTimeline.isInitialized).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(2)
assertThat(fakeTimeline.isInitialized).isTrue()
}
assertThat(fakeTimeline.isInitialized).isFalse()
}
@Test @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(

View file

@ -71,8 +71,8 @@ class NetworkMonitorImpl @Inject constructor(
private fun listenToConnectionChanges() { private fun listenToConnectionChanges() {
val request = NetworkRequest.Builder() val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI) // .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) // .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() .build()
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)

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_onboarding_welcome_title">"Sei in deinem Element"</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="screen_bug_report_editor_placeholder">"Beschreibe den Fehler…"</string>
<string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string>
<string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string>
</resources>

View file

@ -53,6 +53,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl) testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test) testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 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.roomdetails.blockuser
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.theme.LocalColors
@Composable
internal fun BlockUserSection(state: RoomMemberDetailsState, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
if (state.isBlocked) {
PreferenceText(
title = stringResource(R.string.screen_dm_details_unblock_user),
icon = Icons.Outlined.Block,
onClick = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
)
} else {
PreferenceText(
title = stringResource(R.string.screen_dm_details_block_user),
icon = Icons.Outlined.Block,
tintColor = LocalColors.current.textActionCritical,
onClick = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) },
)
}
}
}
@Composable
internal fun BlockUserDialogs(state: RoomMemberDetailsState) {
when (state.displayConfirmationDialog) {
null -> Unit
RoomMemberDetailsState.ConfirmationDialog.Block -> {
BlockConfirmationDialog(
onBlockAction = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
)
}
RoomMemberDetailsState.ConfirmationDialog.Unblock -> {
UnblockConfirmationDialog(
onUnblockAction = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
)
}
}
}
@Composable
internal fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
content = stringResource(R.string.screen_dm_details_block_alert_description),
submitText = stringResource(R.string.screen_dm_details_block_alert_action),
onSubmitClicked = onBlockAction,
onDismiss = onDismiss
)
}
@Composable
internal fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
content = stringResource(R.string.screen_dm_details_unblock_alert_description),
submitText = stringResource(R.string.screen_dm_details_unblock_alert_action),
onSubmitClicked = onUnblockAction,
onDismiss = onDismiss
)
}

View file

@ -59,7 +59,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
object RoomMemberList : NavTarget object RoomMemberList : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -74,14 +74,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
NavTarget.RoomMemberList -> { NavTarget.RoomMemberList -> {
val roomMemberListCallback = object : RoomMemberListNode.Callback { val roomMemberListCallback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMember: RoomMember) { override fun openRoomMemberDetails(roomMemberId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(roomMember)) backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
} }
} }
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember))) createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
} }
} }
} }

View file

@ -63,7 +63,10 @@ class RoomDetailsNode @AssistedInject constructor(
activityResultLauncher = null, activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title), chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink, text = permalink,
noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found)
) )
}.onFailure {
Timber.e(it)
} }
} }
@ -86,12 +89,21 @@ class RoomDetailsNode @AssistedInject constructor(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
val state = presenter.present() val state = presenter.present()
fun onShareRoom() {
this.onShareRoom(context)
}
fun onShareMember(roomMember: RoomMember) {
this.onShareMember(context, roomMember)
}
RoomDetailsView( RoomDetailsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
goBack = { navigateUp() }, goBack = this::navigateUp,
onShareRoom = { onShareRoom(context) }, onShareRoom = ::onShareRoom,
onShareMember = { onShareMember(context, it) }, onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList, openRoomMemberList = ::openRoomMemberList,
) )
} }

View file

@ -18,78 +18,70 @@ package io.element.android.features.roomdetails.impl
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.State
import androidx.compose.runtime.collectAsState
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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import kotlinx.coroutines.Dispatchers import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
private val sessionId: SessionId,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
) : Presenter<RoomDetailsState> { ) : Presenter<RoomDetailsState> {
@Composable @Composable
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember { val leaveRoomWarning = remember {
mutableStateOf<LeaveRoomWarning?>(null) mutableStateOf<LeaveRoomWarning?>(null)
} }
var error by remember { val error = remember {
mutableStateOf<RoomDetailsError?>(null) mutableStateOf<RoomDetailsError?>(null)
} }
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { room.updateMembers()
memberCount = runCatching { room.memberCount() }
.fold(
onSuccess = { Async.Success(it) },
onFailure = { Async.Failure(it) }
)
}
} }
val dmMember = room.getDmMember() val membersState by room.membersStateFlow.collectAsState()
val roomType = if (dmMember != null) { val memberCount by getMemberCount(membersState)
RoomDetailsType.Dm(dmMember) val dmMember by room.getDirectRoomMember(membersState)
} else { val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
RoomDetailsType.Room val roomType = getRoomType(dmMember)
}
fun handleEvents(event: RoomDetailsEvent) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
is RoomDetailsEvent.LeaveRoom -> { is RoomDetailsEvent.LeaveRoom -> {
if (event.needsConfirmation) { coroutineScope.leaveRoom(
leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount) needsConfirmation = event.needsConfirmation,
} else { memberCount = memberCount,
coroutineScope.launch(Dispatchers.IO) { leaveRoomWarning = leaveRoomWarning,
room.leave() error = error,
.onSuccess { )
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error = RoomDetailsError.AlertGeneric
}
leaveRoomWarning = null
}
}
} }
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error = null RoomDetailsEvent.ClearError -> error.value = null
} }
} }
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
return RoomDetailsState( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId.value,
roomName = room.name ?: room.displayName, roomName = room.name ?: room.displayName,
@ -98,10 +90,66 @@ class RoomDetailsPresenter @Inject constructor(
roomTopic = room.topic, roomTopic = room.topic,
memberCount = memberCount, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
displayLeaveRoomWarning = leaveRoomWarning, displayLeaveRoomWarning = leaveRoomWarning.value,
error = error, error = error.value,
roomType = roomType, roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@Composable
private fun roomMemberDetailsPresenter(dmMemberState: RoomMember?) = remember(dmMemberState) {
dmMemberState?.let { roomMember ->
roomMembersDetailsPresenterFactory.create(roomMember.userId)
}
}
@Composable
private fun getRoomType(dmMember: RoomMember?): State<RoomDetailsType> = remember(dmMember) {
derivedStateOf {
if (dmMember != null) {
RoomDetailsType.Dm(dmMember)
} else {
RoomDetailsType.Room
}
}
}
@Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
return remember(membersState) {
derivedStateOf {
when (membersState) {
MatrixRoomMembersState.Unknown -> Async.Uninitialized
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.size)
}
}
}
}
private fun CoroutineScope.leaveRoom(
needsConfirmation: Boolean,
memberCount: Async<Int>,
leaveRoomWarning: MutableState<LeaveRoomWarning?>,
error: MutableState<RoomDetailsError?>,
) = launch(coroutineDispatchers.io) {
if (needsConfirmation) {
leaveRoomWarning.value = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
} else {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error.value = RoomDetailsError.AlertGeneric
}
leaveRoomWarning.value = null
}
}
} }

View file

@ -16,10 +16,8 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
data class RoomDetailsState( data class RoomDetailsState(
@ -33,6 +31,7 @@ data class RoomDetailsState(
val displayLeaveRoomWarning: LeaveRoomWarning?, val displayLeaveRoomWarning: LeaveRoomWarning?,
val error: RoomDetailsError?, val error: RoomDetailsError?,
val roomType: RoomDetailsType, val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )

View file

@ -71,5 +71,6 @@ fun aRoomDetailsState() = RoomDetailsState(
displayLeaveRoomWarning = null, displayLeaveRoomWarning = null,
error = null, error = null,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null,
eventSink = {} eventSink = {}
) )

View file

@ -42,7 +42,8 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.members.details.BlockSection import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberShareSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberShareSection
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -116,7 +117,7 @@ fun RoomDetailsView(
} }
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
val memberCount = (state.memberCount as? Async.Success<Int>)?.state val memberCount = state.memberCount.dataOrNull()
MembersSection( MembersSection(
memberCount = memberCount, memberCount = memberCount,
isLoading = state.memberCount.isLoading(), isLoading = state.memberCount.isLoading(),
@ -135,10 +136,11 @@ fun RoomDetailsView(
}) })
} }
is RoomDetailsType.Dm -> { is RoomDetailsType.Dm -> {
BlockSection( if (state.roomMemberDetailsState != null) {
isBlocked = state.roomType.roomMember.isIgnored, val roomMemberState = state.roomMemberDetailsState
onToggleBlock = { /*TODO*/ } BlockUserSection(roomMemberState)
) BlockUserDialogs(roomMemberState)
}
} }
} }

View file

@ -20,15 +20,14 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import javax.inject.Named import javax.inject.Named
@Module @Module
@ -44,22 +43,14 @@ interface RoomMemberBindsModule {
@ContributesTo(RoomScope::class) @ContributesTo(RoomScope::class)
object RoomMemberProvidesModule { object RoomMemberProvidesModule {
@Provides
fun provideRoomDetailsPresenter(
matrixClient: MatrixClient,
room: MatrixRoom,
roomMembershipObserver: RoomMembershipObserver,
): RoomDetailsPresenter {
return RoomDetailsPresenter(matrixClient.sessionId, room, roomMembershipObserver)
}
@Provides @Provides
fun provideRoomMemberDetailsPresenterFactory( fun provideRoomMemberDetailsPresenterFactory(
matrixClient: MatrixClient,
room: MatrixRoom, room: MatrixRoom,
): RoomMemberDetailsPresenter.Factory { ): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(room, roomMember) return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
} }
} }
} }

View file

@ -14,15 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.fixtures package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.test.UnconfinedTestDispatcher
// TODO Move to common module to reuse sealed interface RoomMemberListEvents {
internal fun testCoroutineDispatchers() = CoroutineDispatchers( data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
io = UnconfinedTestDispatcher(), }
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)

View file

@ -26,31 +26,25 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor( class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val room: MatrixRoom,
private val presenter: RoomMemberListPresenter, private val presenter: RoomMemberListPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberDetails(roomMember: RoomMember) fun openRoomMemberDetails(roomMemberId: UserId)
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
private fun onUserSelected(matrixUser: MatrixUser) { private fun openRoomMemberDetails(roomMemberId: UserId) {
val member = room.getMember(matrixUser.id) callbacks.forEach {
if (member != null) { it.openRoomMemberDetails(roomMemberId)
callbacks.forEach { it.openRoomMemberDetails(member) }
} else {
Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}")
} }
} }
@ -61,7 +55,7 @@ class RoomMemberListNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackPressed = { navigateUp() }, onBackPressed = { navigateUp() },
onUserSelected = ::onUserSelected, onMemberSelected = this::openRoomMemberDetails,
) )
} }
} }

View file

@ -27,10 +27,11 @@ import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
@ -39,6 +40,8 @@ class RoomMemberListPresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory, private val userListPresenterFactory: UserListPresenter.Factory,
@Named("RoomMembers") private val userListDataSource: UserListDataSource, @Named("RoomMembers") private val userListDataSource: UserListDataSource,
private val userListDataStore: UserListDataStore, private val userListDataStore: UserListDataStore,
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy { private val userListPresenter by lazy {
@ -53,14 +56,16 @@ class RoomMemberListPresenter @Inject constructor(
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
val userListState = userListPresenter.present() val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) } val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { withContext(coroutineDispatchers.io) {
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList()) allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
} }
} }
return RoomMemberListState( return RoomMemberListState(
allUsers = allUsers.value, allUsers = allUsers.value,
userListState = userListState userListState = userListState,
) )
} }
} }

View file

@ -18,11 +18,11 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.userlist.api.UserListState import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState( data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>, val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState, val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
) )

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.CenterAlignedT
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -59,8 +60,13 @@ fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {}, onMemberSelected: (UserId) -> Unit = {},
) { ) {
fun onUserSelected(user: MatrixUser) {
onMemberSelected(user.id)
}
Scaffold( Scaffold(
topBar = { topBar = {
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {
@ -76,7 +82,7 @@ fun RoomMemberListView(
) { ) {
UserListView( UserListView(
state = state.userListState, state = state.userListState,
onUserSelected = onUserSelected, onUserSelected = ::onUserSelected,
) )
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {

View file

@ -18,26 +18,39 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class RoomUserListDataSource @Inject constructor( class RoomUserListDataSource @Inject constructor(
private val room: MatrixRoom private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : UserListDataSource { ) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> { override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
return room.members().filter { member -> val roomMembers = room.membersStateFlow
if (query.isBlank()) { .dropWhile { it !is MatrixRoomMembersState.Ready }
true .first()
} else { .roomMembers()
.orEmpty()
val filteredMembers = if (query.isBlank()) {
roomMembers
} else {
roomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) member.userId.value.contains(query, ignoreCase = true)
|| member.displayName?.contains(query, ignoreCase = true).orFalse() || member.displayName?.contains(query, ignoreCase = true).orFalse()
} }
}.map(::mapMemberToMatrixUser) }
filteredMembers.map(::mapMemberToMatrixUser)
} }
override suspend fun getProfile(userId: UserId): MatrixUser? { override suspend fun getProfile(userId: UserId): MatrixUser? {
@ -55,5 +68,4 @@ class RoomUserListDataSource @Inject constructor(
) )
) )
} }
} }

View file

@ -16,4 +16,8 @@
package io.element.android.features.roomdetails.impl.members.details package io.element.android.features.roomdetails.impl.members.details
sealed interface RoomMemberDetailsEvents sealed interface RoomMemberDetailsEvents {
data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
object ClearConfirmationDialog : RoomMemberDetailsEvents
}

View file

@ -32,7 +32,6 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.RoomMember
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.androidutils.R as AndroidUtilsR import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ -44,19 +43,18 @@ class RoomMemberDetailsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val member: RoomMember, val roomMemberId: UserId,
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.member) private val presenter = presenterFactory.create(inputs.roomMemberId)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
fun onShareUser() { fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.member.userId) val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink -> permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent( startSharePlainTextIntent(
context = context, context = context,

View file

@ -17,49 +17,108 @@
package io.element.android.features.roomdetails.impl.members.details package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.room.getRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor( class RoomMemberDetailsPresenter @AssistedInject constructor(
private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
@Assisted private val roomMember: RoomMember, @Assisted private val roomMemberId: UserId,
) : Presenter<RoomMemberDetailsState> { ) : Presenter<RoomMemberDetailsState> {
interface Factory { interface Factory {
fun create(roomMember: RoomMember): RoomMemberDetailsPresenter fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
} }
@Composable @Composable
override fun present(): RoomMemberDetailsState { override fun present(): RoomMemberDetailsState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val roomMember by room.getRoomMember(roomMemberId)
// the room member is not really live...
val isBlocked = remember {
mutableStateOf(roomMember?.isIgnored.orFalse())
}
LaunchedEffect(Unit) {
room.updateMembers()
}
// fun handleEvents(event: RoomMemberDetailsEvents) { fun handleEvents(event: RoomMemberDetailsEvents) {
// when (event) { when (event) {
// } is RoomMemberDetailsEvents.BlockUser -> {
// } if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
coroutineScope.blockUser(roomMemberId, isBlocked)
}
}
is RoomMemberDetailsEvents.UnblockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
coroutineScope.unblockUser(roomMemberId, isBlocked)
}
}
RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
}
}
val userName by produceState(initialValue = roomMember.displayName) { val userName by produceState(initialValue = roomMember?.displayName) {
room.userDisplayName(roomMember.userId).onSuccess { displayName -> room.userDisplayName(roomMemberId).onSuccess { displayName ->
if (displayName != null) value = displayName if (displayName != null) value = displayName
} }
} }
val userAvatar by produceState(initialValue = roomMember.avatarUrl) { val userAvatar by produceState(initialValue = roomMember?.avatarUrl) {
room.userAvatarUrl(roomMember.userId).onSuccess { avatarUrl -> room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl ->
if (avatarUrl != null) value = avatarUrl if (avatarUrl != null) value = avatarUrl
} }
} }
return RoomMemberDetailsState( return RoomMemberDetailsState(
userId = roomMember.userId.value, userId = roomMemberId.value,
userName = userName, userName = userName,
avatarUrl = userAvatar, avatarUrl = userAvatar,
isBlocked = roomMember.isIgnored, isBlocked = isBlocked.value,
// eventSink = ::handleEvents displayConfirmationDialog = confirmationDialog,
isCurrentUser = roomMember?.userId == client.sessionId,
eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
client.ignoreUser(userId)
.map {
isBlockedState.value = true
room.updateMembers()
}
}
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
client.unignoreUser(userId)
.map {
isBlockedState.value = false
room.updateMembers()
}
}
} }

View file

@ -21,5 +21,11 @@ data class RoomMemberDetailsState(
val userName: String?, val userName: String?,
val avatarUrl: String?, val avatarUrl: String?,
val isBlocked: Boolean, val isBlocked: Boolean,
// val eventSink: (RoomMemberDetailsEvents) -> Unit val displayConfirmationDialog: ConfirmationDialog? = null,
) val isCurrentUser: Boolean,
val eventSink: (RoomMemberDetailsEvents) -> Unit
) {
enum class ConfirmationDialog {
Block, Unblock
}
}

View file

@ -24,6 +24,8 @@ open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberD
aRoomMemberDetailsState(), aRoomMemberDetailsState(),
aRoomMemberDetailsState().copy(userName = null), aRoomMemberDetailsState().copy(userName = null),
aRoomMemberDetailsState().copy(isBlocked = true), aRoomMemberDetailsState().copy(isBlocked = true),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
// Add other states here // Add other states here
) )
} }
@ -33,5 +35,6 @@ fun aRoomMemberDetailsState() = RoomMemberDetailsState(
userName = "Daniel", userName = "Daniel",
avatarUrl = null, avatarUrl = null,
isBlocked = false, isBlocked = false,
// eventSink = {}, isCurrentUser = false,
eventSink = {},
) )

View file

@ -39,12 +39,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -86,9 +89,10 @@ fun RoomMemberDetailsView(
// TODO implement send DM // TODO implement send DM
}) })
BlockSection(isBlocked = state.isBlocked, onToggleBlock = { if (!state.isCurrentUser) {
// TODO implement block & unblock BlockUserSection(state)
}) BlockUserDialogs(state)
}
} }
} }
} }
@ -139,24 +143,6 @@ internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier =
} }
} }
@Composable
internal fun BlockSection(isBlocked: Boolean, onToggleBlock: () -> Unit, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
if (isBlocked) {
PreferenceText(
title = stringResource(R.string.screen_dm_details_unblock_user),
icon = Icons.Outlined.Block,
)
} else {
PreferenceText(
title = stringResource(R.string.screen_dm_details_block_user),
icon = Icons.Outlined.Block,
tintColor = LocalColors.current.textActionCritical,
)
}
}
}
@Preview @Preview
@Composable @Composable
fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"1 Person"</item>
<item quantity="other">"%1$d Personen"</item>
</plurals>
<string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_dm_details_block_alert_action">"Blockieren"</string>
<string name="screen_dm_details_block_user">"Nutzer blockieren"</string>
<string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string>
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string>
<string name="screen_room_details_topic_title">"Thema"</string>
</resources>

View file

@ -4,18 +4,18 @@
<item quantity="one">"Una persona"</item> <item quantity="one">"Una persona"</item>
<item quantity="other">"%1$d personas"</item> <item quantity="other">"%1$d personas"</item>
</plurals> </plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."</string>
<string name="screen_room_details_encryption_enabled_title">"Cifrado de mensajes activado"</string>
<string name="screen_room_details_invite_people_title">"Invitar a otras personas"</string>
<string name="screen_room_details_share_room_title">"Compartir sala"</string>
<string name="screen_dm_details_block_alert_action">"Bloquear"</string> <string name="screen_dm_details_block_alert_action">"Bloquear"</string>
<string name="screen_dm_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puedes revertir esta acción en cualquier momento."</string> <string name="screen_dm_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."</string>
<string name="screen_dm_details_block_user">"Bloquear usuario"</string> <string name="screen_dm_details_block_user">"Bloquear usuario"</string>
<string name="screen_dm_details_unblock_alert_action">"Desbloquear"</string> <string name="screen_dm_details_unblock_alert_action">"Desbloquear"</string>
<string name="screen_dm_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string> <string name="screen_dm_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string>
<string name="screen_dm_details_unblock_user">"Desbloquear usuario"</string> <string name="screen_dm_details_unblock_user">"Desbloquear usuario"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."</string>
<string name="screen_room_details_encryption_enabled_title">"Cifrado de mensajes activado"</string>
<string name="screen_room_details_invite_people_title">"Invitar a otras personas"</string>
<string name="screen_room_details_leave_room_title">"Salir de la sala"</string> <string name="screen_room_details_leave_room_title">"Salir de la sala"</string>
<string name="screen_room_details_people_title">"Personas"</string> <string name="screen_room_details_people_title">"Personas"</string>
<string name="screen_room_details_security_title">"Seguridad"</string> <string name="screen_room_details_security_title">"Seguridad"</string>
<string name="screen_room_details_share_room_title">"Compartir sala"</string>
<string name="screen_room_details_topic_title">"Tema"</string> <string name="screen_room_details_topic_title">"Tema"</string>
</resources> </resources>

View file

@ -4,18 +4,18 @@
<item quantity="one">"1 persona"</item> <item quantity="one">"1 persona"</item>
<item quantity="other">"%1$d persone"</item> <item quantity="other">"%1$d persone"</item>
</plurals> </plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."</string>
<string name="screen_room_details_encryption_enabled_title">"Crittografia messaggi abilitata"</string>
<string name="screen_room_details_invite_people_title">"Invita persone"</string>
<string name="screen_room_details_share_room_title">"Condividi stanza"</string>
<string name="screen_dm_details_block_alert_action">"Blocca"</string> <string name="screen_dm_details_block_alert_action">"Blocca"</string>
<string name="screen_dm_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti i loro messaggi saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string> <string name="screen_dm_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string>
<string name="screen_dm_details_block_user">"Blocca utente"</string> <string name="screen_dm_details_block_user">"Blocca utente"</string>
<string name="screen_dm_details_unblock_alert_action">"Sblocca"</string> <string name="screen_dm_details_unblock_alert_action">"Sblocca"</string>
<string name="screen_dm_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string> <string name="screen_dm_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string>
<string name="screen_dm_details_unblock_user">"Sblocca utente"</string> <string name="screen_dm_details_unblock_user">"Sblocca utente"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."</string>
<string name="screen_room_details_encryption_enabled_title">"Crittografia messaggi abilitata"</string>
<string name="screen_room_details_invite_people_title">"Invita persone"</string>
<string name="screen_room_details_leave_room_title">"Esci dalla stanza"</string> <string name="screen_room_details_leave_room_title">"Esci dalla stanza"</string>
<string name="screen_room_details_people_title">"Persone"</string> <string name="screen_room_details_people_title">"Persone"</string>
<string name="screen_room_details_security_title">"Sicurezza"</string> <string name="screen_room_details_security_title">"Sicurezza"</string>
<string name="screen_room_details_share_room_title">"Condividi stanza"</string>
<string name="screen_room_details_topic_title">"Oggetto"</string> <string name="screen_room_details_topic_title">"Oggetto"</string>
</resources> </resources>

View file

@ -5,18 +5,18 @@
<item quantity="few"></item> <item quantity="few"></item>
<item quantity="other">"%1$d persoane"</item> <item quantity="other">"%1$d persoane"</item>
</plurals> </plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
<string name="screen_room_details_encryption_enabled_title">"Criptarea mesajelor este activată"</string>
<string name="screen_room_details_invite_people_title">"Invitați persoane"</string>
<string name="screen_room_details_share_room_title">"Partajați camera"</string>
<string name="screen_dm_details_block_alert_action">"Blocați"</string> <string name="screen_dm_details_block_alert_action">"Blocați"</string>
<string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string> <string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string>
<string name="screen_dm_details_block_user">"Blocați utilizatorul"</string> <string name="screen_dm_details_block_user">"Blocați utilizatorul"</string>
<string name="screen_dm_details_unblock_alert_action">"Deblocați"</string> <string name="screen_dm_details_unblock_alert_action">"Deblocați"</string>
<string name="screen_dm_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string> <string name="screen_dm_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_dm_details_unblock_user">"Deblocați utilizatorul"</string> <string name="screen_dm_details_unblock_user">"Deblocați utilizatorul"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
<string name="screen_room_details_encryption_enabled_title">"Criptarea mesajelor este activată"</string>
<string name="screen_room_details_invite_people_title">"Invitați persoane"</string>
<string name="screen_room_details_leave_room_title">"Părăsiți camera"</string> <string name="screen_room_details_leave_room_title">"Părăsiți camera"</string>
<string name="screen_room_details_people_title">"Persoane"</string> <string name="screen_room_details_people_title">"Persoane"</string>
<string name="screen_room_details_security_title">"Securitate"</string> <string name="screen_room_details_security_title">"Securitate"</string>
<string name="screen_room_details_share_room_title">"Partajați camera"</string>
<string name="screen_room_details_topic_title">"Subiect"</string> <string name="screen_room_details_topic_title">"Subiect"</string>
</resources> </resources>

View file

@ -4,18 +4,18 @@
<item quantity="one">"1 person"</item> <item quantity="one">"1 person"</item>
<item quantity="other">"%1$d people"</item> <item quantity="other">"%1$d people"</item>
</plurals> </plurals>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string> <string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string> <string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string> <string name="screen_dm_details_block_user">"Block user"</string>
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string> <string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string> <string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_dm_details_unblock_user">"Unblock user"</string> <string name="screen_dm_details_unblock_user">"Unblock user"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string> <string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string> <string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</string> <string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_topic_title">"Topic"</string> <string name="screen_room_details_topic_title">"Topic"</string>
</resources> </resources>

View file

@ -23,9 +23,14 @@ import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.LeaveRoomWarning import io.element.android.features.roomdetails.impl.LeaveRoomWarning
import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -34,7 +39,10 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
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_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -46,11 +54,21 @@ import org.junit.Test
class RoomDetailsPresenterTests { class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver() private val roomMembershipObserver = RoomMembershipObserver()
private val testCoroutineDispatchers = testCoroutineDispatchers()
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
}
}
return RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers, roomMemberDetailsPresenterFactory)
}
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -59,7 +77,7 @@ class RoomDetailsPresenterTests {
Truth.assertThat(initialState.roomName).isEqualTo(room.name) Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic) Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -68,23 +86,42 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - room member count is calculated asynchronously`() = runTest { fun `present - room member count is calculated asynchronously`() = runTest {
val error = RuntimeException()
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val roomMembers = listOf(
aRoomMember(A_USER_ID),
aRoomMember(A_USER_ID_2),
)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
val finalState = awaitItem() room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0)) val loadingState = awaitItem()
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
//skipItems(1)
val failureState = awaitItem()
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
//skipItems(1)
val successState = awaitItem()
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(roomMembers.size))
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null) val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -96,16 +133,22 @@ class RoomDetailsPresenterTests {
} }
@Test @Test
fun `present - can handle error while fetching member count`() = runTest { fun `present - initial state with DM member sets custom DM roomType`() = runTest {
val room = aMatrixRoom(name = null).apply { val myRoomMember = aRoomMember(A_SESSION_ID)
givenFetchMemberResult(Result.failure(Throwable())) val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
).apply {
val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
} }
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) val initialState = awaitItem()
Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java) Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -113,15 +156,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false) val room = aMatrixRoom(isPublic = false).apply {
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
@ -130,15 +172,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember())) val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
@ -147,15 +188,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation shows a generic warning`() = runTest { fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
@ -164,15 +204,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave without confirmation leaves the room`() = runTest { fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -189,14 +228,11 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable()) givenLeaveRoomError(Throwable())
} }
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem() val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull() Truth.assertThat(errorState.error).isNotNull()
@ -206,24 +242,28 @@ class RoomDetailsPresenterTests {
} }
} }
fun aMatrixClient(
sessionId: SessionId = A_SESSION_ID,
) = FakeMatrixClient()
fun aMatrixRoom( fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
displayName: String = "A fallback display name", displayName: String = "A fallback display name",
topic: String? = "A topic", topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg", avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false,
) = FakeMatrixRoom( ) = FakeMatrixRoom(
roomId = roomId, roomId = roomId,
name = name, name = name,
displayName = displayName, displayName = displayName,
topic = topic, topic = topic,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
members = members,
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
isPublic = isPublic, isPublic = isPublic,
isDirect = isDirect,
) )
fun aRoomMember( fun aRoomMember(

View file

@ -29,8 +29,12 @@ import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList import okhttp3.internal.toImmutableList
import org.junit.Test import org.junit.Test
@ -38,6 +42,8 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomMemberListPresenterTests { class RoomMemberListPresenterTests {
private val testCoroutineDispatchers = testCoroutineDispatchers()
@Test @Test
fun `present - search is done automatically on start, but is async`() = runTest { fun `present - search is done automatically on start, but is async`() = runTest {
val searchResult = listOf(aMatrixUser()) val searchResult = listOf(aMatrixUser())
@ -52,7 +58,14 @@ class RoomMemberListPresenterTests {
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore) ) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
} }
val presenter = RoomMemberListPresenter(userListFactory, userListDataSource, userListDataStore) val fakeRoom = FakeMatrixRoom()
val presenter = RoomMemberListPresenter(
userListPresenterFactory = userListFactory,
userListDataSource = userListDataSource,
userListDataStore = userListDataStore,
room = fakeRoom,
coroutineDispatchers = testCoroutineDispatchers
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -20,9 +20,13 @@ import app.cash.molecule.RecompositionClock
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 import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixClient
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.aRoomMember import io.element.android.features.roomdetails.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -30,14 +34,17 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomMemberDetailsPresenterTests { class RoomMemberDetailsPresenterTests {
private val matrixClient = aMatrixClient()
@Test @Test
fun `present - returns the room member's data, then updates it if needed`() = runTest { fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success("A custom name")) givenUserDisplayNameResult(Result.success("A custom name"))
givenUserAvatarUrlResult(Result.success("A custom avatar")) givenUserAvatarUrlResult(Result.success("A custom avatar"))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -55,12 +62,13 @@ class RoomMemberDetailsPresenterTests {
@Test @Test
fun `present - will recover when retrieving room member details fails`() = runTest { fun `present - will recover when retrieving room member details fails`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.failure(Throwable())) givenUserDisplayNameResult(Result.failure(Throwable()))
givenUserAvatarUrlResult(Result.failure(Throwable())) givenUserAvatarUrlResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -74,12 +82,13 @@ class RoomMemberDetailsPresenterTests {
@Test @Test
fun `present - will fallback to original data if the updated data is null`() = runTest { fun `present - will fallback to original data if the updated data is null`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success(null)) givenUserDisplayNameResult(Result.success(null))
givenUserAvatarUrlResult(Result.success(null)) givenUserAvatarUrlResult(Result.success(null))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -90,4 +99,63 @@ class RoomMemberDetailsPresenterTests {
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
} }
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true))
val dialogState = awaitItem()
Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Block)
dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
Truth.assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
}
}
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isTrue()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isFalse()
}
}
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem()
Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Unblock)
dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
Truth.assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
}
}
} }

View file

@ -20,4 +20,5 @@ sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
object DismissRequestVerificationPrompt : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents
object ToggleSearchResults : RoomListEvents
} }

View file

@ -79,6 +79,7 @@ class RoomListPresenter @Inject constructor(
Timber.v("RoomSummaries size = ${roomSummaries.size}") Timber.v("RoomSummaries size = ${roomSummaries.size}")
val mappedRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember { mutableStateOf(persistentListOf()) }
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember { val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
mutableStateOf(persistentListOf()) mutableStateOf(persistentListOf())
} }
@ -101,41 +102,51 @@ class RoomListPresenter @Inject constructor(
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
} }
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
fun handleEvents(event: RoomListEvents) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.ToggleSearchResults -> {
if (displaySearchResults) {
filter = ""
}
displaySearchResults =! displaySearchResults
}
} }
} }
LaunchedEffect(roomSummaries, filter) { LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) mappedRoomSummaries.value = if (roomSummaries.isEmpty()) {
RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
} else {
mapRoomSummaries(roomSummaries).toImmutableList()
}
filteredRoomSummaries.value = updateFilteredRoomSummaries(mappedRoomSummaries.value, filter)
} }
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
return RoomListState( return RoomListState(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value, roomList = mappedRoomSummaries.value,
filter = filter, filter = filter,
filteredRoomList = filteredRoomSummaries.value,
displayVerificationPrompt = displayVerificationPrompt, displayVerificationPrompt = displayVerificationPrompt,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
displayInvites = invites.isNotEmpty(), displayInvites = invites.isNotEmpty(),
displaySearchResults = displaySearchResults,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private suspend fun updateFilteredRoomSummaries(roomSummaries: List<RoomSummary>?, filter: String): ImmutableList<RoomListRoomSummary> { private fun updateFilteredRoomSummaries(mappedRoomSummaries: ImmutableList<RoomListRoomSummary>, filter: String): ImmutableList<RoomListRoomSummary> {
if (roomSummaries.isNullOrEmpty()) { return when {
return RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList() filter.isEmpty() -> emptyList()
} else -> mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
val mappedRoomSummaries = mapRoomSummaries(roomSummaries)
return if (filter.isEmpty()) {
mappedRoomSummaries
} else {
mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
}.toImmutableList() }.toImmutableList()
} }

View file

@ -26,10 +26,12 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomListState( data class RoomListState(
val matrixUser: MatrixUser?, val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>, val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String, val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean, val displayVerificationPrompt: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val displayInvites: Boolean, val displayInvites: Boolean,
val displaySearchResults: Boolean,
val eventSink: (RoomListEvents) -> Unit val eventSink: (RoomListEvents) -> Unit
) )

View file

@ -36,6 +36,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false), aRoomListState().copy(hasNetworkConnection = false),
aRoomListState().copy(displayInvites = true), aRoomListState().copy(displayInvites = true),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
) )
} }
@ -43,10 +45,12 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")), matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")),
roomList = aRoomListRoomSummaryList(), roomList = aRoomListRoomSummaryList(),
filter = "filter", filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true, hasNetworkConnection = true,
snackbarMessage = null, snackbarMessage = null,
displayVerificationPrompt = false, displayVerificationPrompt = false,
displayInvites = false, displayInvites = false,
displaySearchResults = false,
eventSink = {} eventSink = {}
) )

View file

@ -64,6 +64,8 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultContent
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -91,15 +93,27 @@ fun RoomListView(
onCreateRoomClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {},
onInvitesClicked: () -> Unit = {}, onInvitesClicked: () -> Unit = {},
) { ) {
RoomListContent( Column(modifier = modifier) {
state = state, ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
modifier = modifier, Box {
onRoomClicked = onRoomClicked, RoomListContent(
onOpenSettings = onOpenSettings, state = state,
onVerifyClicked = onVerifyClicked, onRoomClicked = onRoomClicked,
onCreateRoomClicked = onCreateRoomClicked, onOpenSettings = onOpenSettings,
onInvitesClicked = onInvitesClicked, onVerifyClicked = onVerifyClicked,
) onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchResultView(
state = state,
onRoomClicked = onRoomClicked,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
}
}
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -163,16 +177,14 @@ fun RoomListContent(
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
Column { RoomListTopBar(
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) matrixUser = state.matrixUser,
RoomListTopBar( areSearchResultsDisplayed = state.displaySearchResults,
matrixUser = state.matrixUser, onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
filter = state.filter, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onOpenSettings = onOpenSettings,
onOpenSettings = onOpenSettings, scrollBehavior = scrollBehavior,
scrollBehavior = scrollBehavior, )
)
}
}, },
content = { padding -> content = { padding ->
Column( Column(
@ -306,7 +318,7 @@ internal fun PreviewRequestVerificationHeaderDark() {
} }
} }
private fun RoomListRoomSummary.contentType() = isPlaceholder internal fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview @Preview
@Composable @Composable
@ -322,3 +334,11 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl
private fun ContentToPreview(state: RoomListState) { private fun ContentToPreview(state: RoomListState) {
RoomListView(state) RoomListView(state)
} }
@Preview
@Composable
internal fun RoomListSearchResultContentPreview() {
ElementPreviewLight {
RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {})
}
}

View file

@ -20,57 +20,42 @@ package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ContentAlpha
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomlist.impl.R import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomListTopBar( fun RoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser?,
filter: String, areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit, onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -79,124 +64,26 @@ fun RoomListTopBar(
tag = "RoomListScreen", tag = "RoomListScreen",
msg = "TopBar" msg = "TopBar"
) )
var searchWidgetStateIsOpened by rememberSaveable { mutableStateOf(false) }
fun closeFilter() { fun closeFilter() {
onFilterChanged("") onFilterChanged("")
searchWidgetStateIsOpened = false
} }
BackHandler(enabled = searchWidgetStateIsOpened) { BackHandler(enabled = areSearchResultsDisplayed) {
closeFilter() closeFilter()
onToggleSearch()
} }
if (searchWidgetStateIsOpened) { DefaultRoomListTopBar(
SearchRoomListTopBar( matrixUser = matrixUser,
text = filter, onOpenSettings = onOpenSettings,
onFilterChanged = onFilterChanged, onSearchClicked = onToggleSearch,
onCloseClicked = ::closeFilter, scrollBehavior = scrollBehavior,
scrollBehavior = scrollBehavior, modifier = modifier,
modifier = modifier,
)
} else {
DefaultRoomListTopBar(
matrixUser = matrixUser,
onOpenSettings = onOpenSettings,
onSearchClicked = {
searchWidgetStateIsOpened = true
},
scrollBehavior = scrollBehavior,
modifier = modifier,
)
}
}
@Composable
fun SearchRoomListTopBar(
text: String,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
onFilterChanged: (String) -> Unit = {},
onCloseClicked: () -> Unit = {},
) {
var filterState by textFieldState(stateValue = text)
val focusRequester = remember { FocusRequester() }
TopAppBar(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
TextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = filterState,
textStyle = TextStyle(
fontSize = 17.sp
),
onValueChange = {
filterState = it
onFilterChanged(it)
},
placeholder = {
Text(
text = stringResource(id = StringR.string.action_search),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = ContentAlpha.medium)
)
},
singleLine = true,
trailingIcon = {
if (text.isNotEmpty()) {
IconButton(
onClick = {
onFilterChanged("")
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "clear",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
},
)
},
navigationIcon = {
IconButton(
onClick = {
onCloseClicked()
}
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "close",
tint = MaterialTheme.colorScheme.onBackground
)
}
},
windowInsets = WindowInsets(0.dp)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Preview
@Composable
internal fun SearchRoomListTopBarLightPreview() = ElementPreviewLight { SearchRoomListTopBarPreview() }
@Preview
@Composable
internal fun SearchRoomListTopBarDarkPreview() = ElementPreviewDark { SearchRoomListTopBarPreview() }
@Composable
private fun SearchRoomListTopBarPreview() {
SearchRoomListTopBar(
text = "Hello",
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DefaultRoomListTopBar( private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser?,
@ -216,21 +103,19 @@ private fun DefaultRoomListTopBar(
}, },
navigationIcon = { navigationIcon = {
if (matrixUser != null) { if (matrixUser != null) {
IconButton(onClick = {}) { IconButton(
Avatar(matrixUser.avatarData) modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
Avatar(matrixUser.avatarData, contentDescription = stringResource(StringR.string.common_settings))
} }
} }
}, },
actions = { actions = {
IconButton( IconButton(
onClick = onSearchClicked onClick = onSearchClicked,
) { ) {
Icon(Icons.Default.Search, contentDescription = "search") Icon(Icons.Default.Search, contentDescription = stringResource(StringR.string.action_search))
}
IconButton(
onClick = onOpenSettings
) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
} }
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@ -246,6 +131,7 @@ internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { Default
@Composable @Composable
internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() } internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DefaultRoomListTopBarPreview() { private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar( DefaultRoomListTopBar(

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2023 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.roomlist.impl.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.RoomListState
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.copy
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R
@Composable
internal fun RoomListSearchResultView(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = state.displaySearchResults,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = modifier
.applyIf(state.displaySearchResults, ifTrue = {
// Disable input interaction to underlying views
pointerInput(Unit) {}
})
) {
if (state.displaySearchResults) {
RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RoomListSearchResultContent(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
fun onBackButtonPressed() {
state.eventSink(RoomListEvents.ToggleSearchResults)
}
fun onRoomClicked(room: RoomListRoomSummary) {
if (room.roomId == null) return
onRoomClicked(room.roomId)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
modifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
},
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
title = {
val filter = state.filter.orEmpty()
val focusRequester = FocusRequester()
TextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = filter,
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
trailingIcon = {
if (filter.isNotEmpty()) {
IconButton(onClick = {
state.eventSink(RoomListEvents.UpdateFilter(""))
}) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_cancel)
)
}
}
}
)
LaunchedEffect(state.displaySearchResults) {
if (state.displaySearchResults) {
focusRequester.requestFocus()
}
}
},
windowInsets = TopAppBarDefaults.windowInsets.copy(top = 0)
)
}
) { padding ->
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
Column(
modifier = Modifier
.padding(padding)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
items(
items = state.filteredRoomList,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
}
}
}
}
}

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_main_space_title">"Alle Chats"</string>
<string name="state_event_avatar_changed_too">"(Avatar wurde ebenfalls geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat seinen Avatar geändert"</string>
<string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string>
<string name="state_event_display_name_changed_from">"%1$s hat den Anzeigenamen von %2$s in %3$s geändert"</string>
<string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s in %2$s geändert"</string>
<string name="state_event_display_name_removed">"%1$s hat den Anzeigenamen entfernt (war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat den Anzeigenamen auf %2$s gesetzt"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s gesetzt"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Du hast den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Raum-Avatar entfernt"</string>
<string name="state_event_room_created">"%1$s hat den Raum erstellt"</string>
<string name="state_event_room_created_by_you">"Du hast den Raum erstellt"</string>
<string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string>
<string name="state_event_room_invite_accepted">"%1$s hat die Einladung angenommen"</string>
<string name="state_event_room_invite_accepted_by_you">"Du hast die Einladung angenommen"</string>
<string name="state_event_room_invite_by_you">"Du hast %1$s eingeladen"</string>
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s ist dem Raum beigetreten"</string>
<string name="state_event_room_join_by_you">"Du bist dem Raum beigetreten"</string>
<string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string>
<string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string>
<string name="state_event_room_leave_by_you">"Du hast den Raum verlassen"</string>
<string name="state_event_room_name_changed">"%1$s hat den Raumnamen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Sie haben den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Raumnamen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Du hast den Raumnamen entfernt"</string>
<string name="state_event_room_reject">"%1$s hat die Einladung abgelehnt"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
<string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string>
<string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string>
<string name="state_event_room_topic_changed">"%1$s hat das Thema geändert zu: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sie haben das Thema geändert zu: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Raumthema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Du hast das Raumthema entfernt"</string>
</resources>

View file

@ -112,6 +112,8 @@ class RoomListPresenterTests {
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem() val withFilterState = awaitItem()
Truth.assertThat(withFilterState.filter).isEqualTo("t") Truth.assertThat(withFilterState.filter).isEqualTo("t")
cancelAndIgnoreRemainingEvents()
} }
} }
@ -168,17 +170,18 @@ class RoomListPresenterTests {
val loadedState = awaitItem() val loadedState = awaitItem()
// Test filtering with result // Test filtering with result
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
skipItems(1) // Filter update
val withNotFilteredRoomState = awaitItem() val withNotFilteredRoomState = awaitItem()
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
Truth.assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1) Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1)
Truth.assertThat(withNotFilteredRoomState.roomList.first()) Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first())
.isEqualTo(aRoomListRoomSummary) .isEqualTo(aRoomListRoomSummary)
// Test filtering without result // Test filtering without result
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
skipItems(1) // Filter update skipItems(1) // Filter update
val withFilteredRoomState = awaitItem() val withFilteredRoomState = awaitItem()
Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada") Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada")
Truth.assertThat(withFilteredRoomState.roomList).isEmpty() Truth.assertThat(withFilteredRoomState.filteredRoomList).isEmpty()
} }
} }

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
interface UserListDataSource { interface UserListDataSource {
//TODO should probably have a flow
suspend fun search(query: String): List<MatrixUser> suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser? suspend fun getProfile(userId: UserId): MatrixUser?
} }

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_session_verification_compare_emojis_title">"Emojis vergleichen"</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Beweise, dass du es bist, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen."</string>
<string name="screen_session_verification_positive_button_initial">"Ich bin bereit"</string>
<string name="screen_session_verification_positive_button_verifying_ongoing">"Warten auf Übereinstimmung"</string>
<string name="screen_session_verification_they_dont_match">"Sie stimmen nicht überein"</string>
<string name="screen_session_verification_they_match">"Sie stimmen überein"</string>
<string name="screen_session_verification_cancelled_title">"Verifizierung abgebrochen"</string>
</resources>

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