diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fdc5586c2..be8d1a370c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v2.4.2 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml index 72f9aee8f1..13e0c3b4ad 100644 --- a/.github/workflows/maestro.yml +++ b/.github/workflows/maestro.yml @@ -16,7 +16,7 @@ jobs: maestro-cloud: name: Maestro test suite runs-on: ubuntu-latest - if: github.event.review.state == 'approved' + if: github.event.review.state == 'approved' || github.event_name == 'workflow_dispatch' strategy: fail-fast: false # Allow one per PR. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dafa94c7b1..e668c17275 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -31,7 +31,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v2.4.2 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run code quality check suite @@ -79,7 +79,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v2.4.2 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7646de42a0..391c6c6b21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,7 +34,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v2.4.2 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 136acfe491..d7951a012b 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -12,22 +12,10 @@ jobs: if: > github.repository == 'vector-im/element-x-android' steps: - - uses: octokit/graphql-action@v2.x + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - 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 }} + project-url: https://github.com/orgs/vector-im/projects/43 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} ex_plorers: name: Add labelled issues to X-Plorer project @@ -35,23 +23,10 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: Element X Feature') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - 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 }} + project-url: https://github.com/orgs/vector-im/projects/73 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} verticals_feature: name: Add labelled issues to Verticals Feature project @@ -59,20 +34,18 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') steps: - - uses: octokit/graphql-action@v2.x - id: add_to_project + - uses: actions/add-to-project@main with: - headers: '{"GraphQL-Features": "projects_next_graphql"}' - query: | - 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_kwDOAM0swc4AHJKW" - GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: https://github.com/orgs/vector-im/projects/57 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + + qa: + name: Add labelled issues to QA project + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'Team: QA') + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/69 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 0fc3113136..69e86158ba 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index 3c9dd07972..a06ac25e2d 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -1,6 +1,7 @@ appId: ${APP_ID} --- -- tapOn: "Settings" +- tapOn: + id: "home_screen-settings" - tapOn: "Sign out" - takeScreenshot: build/maestro/900-SignOutDialg # Ensure cancel cancels diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml index 397a0f70b5..ee3104024c 100644 --- a/.maestro/tests/settings/settings.yaml +++ b/.maestro/tests/settings/settings.yaml @@ -1,6 +1,7 @@ appId: ${APP_ID} --- -- tapOn: "Settings" +- tapOn: + id: "home_screen-settings" - assertVisible: "Rageshake to report bug" - takeScreenshot: build/maestro/600-Settings - tapOn: diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts index 5dbfbec73a..0edb358423 100644 --- a/anvilcodegen/build.gradle.kts +++ b/anvilcodegen/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { implementation(projects.anvilannotations) api(libs.anvil.compiler.api) implementation(libs.anvil.compiler.utils) - implementation("com.squareup:kotlinpoet:1.13.0") + implementation("com.squareup:kotlinpoet:1.13.1") implementation(libs.dagger) compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") kapt("com.google.auto.service:auto-service:1.0.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 342e05532c..4a81d52d46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,16 @@ android:supportsRtl="true" android:theme="@style/Theme.ElementX" tools:targetApi="33"> + + + + + + + + + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index 22929e3d66..2b33e37c7e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -18,6 +18,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope 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.RoomMembershipObserver import io.element.android.services.appnavstate.api.AppNavigationStateService -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -102,7 +102,7 @@ class RoomFlowNode @AssistedInject constructor( private fun fetchRoomMembers() = lifecycleScope.launch { val room = inputs.room - room.fetchMembers() + room.updateMembers() .onFailure { Timber.e(it, "Fail to fetch members for room ${room.roomId}") }.onSuccess { @@ -134,8 +134,18 @@ class RoomFlowNode @AssistedInject constructor( object RoomDetails : NavTarget } + private val timeline = inputs.room.timeline() + @Composable override fun View(modifier: Modifier) { + + DisposableEffect(Unit) { + timeline.initialize() + onDispose { + timeline.dispose() + } + } + Children( navModel = backstack, modifier = modifier, diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index ef611a2f4b..a151be665c 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -24,7 +24,6 @@ import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.activeElement 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.google.common.truth.Truth import io.element.android.features.messages.api.MessagesEntryPoint @@ -81,19 +80,6 @@ class RoomFlowNodeTest { 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 fun `given a room flow node when initialized then it loads messages entry point`() { // GIVEN diff --git a/changelog.d/339.feature b/changelog.d/339.feature new file mode 100644 index 0000000000..4cbf834b1c --- /dev/null +++ b/changelog.d/339.feature @@ -0,0 +1 @@ +Block & unblock users from room details screen. diff --git a/changelog.d/354.feature b/changelog.d/354.feature new file mode 100644 index 0000000000..7d6e15b545 --- /dev/null +++ b/changelog.d/354.feature @@ -0,0 +1 @@ +Improve room list search and general UI diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..8a3c6cdeda --- /dev/null +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Neuer Raum" + "Privater Raum (nur auf Einladung)" + "Raumname" + "Thema (optional)" + \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml index af6e3db1fa..a1ea3b31f0 100644 --- a/features/createroom/impl/src/main/res/values-ro/translations.xml +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -3,6 +3,16 @@ "Cameră nouă" "Invitați persoane" "Adaugați persoane" + "A apărut o eroare la crearea camerei" + "Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior." + "Cameră privată (doar pe bază de invitație)" + "Mesajele nu sunt criptate și oricine le poate citi. Puteți activa criptarea la o dată ulterioară." + "Cameră publică (oricine)" + "Numele camerei" + "e.g. Mici și Cozonaci" + "Creați o cameră" + "Subiect (opțional)" + "Despre ce este această cameră?" "A apărut o eroare la încercarea începerii conversației" "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită." \ No newline at end of file diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 64d59b221c..db7cd06170 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -102,7 +102,7 @@ class CreateRoomRootPresenterTests { }.test { val initialState = awaitItem() val matrixUser = MatrixUser(UserId("@name:domain")) - val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult:domain")) + val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain")) fakeMatrixClient.givenFindDmResult(fakeDmResult) diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..95e63cf5f2 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,6 @@ + + + "Chat ablehnen" + "Keine Einladungen" + "%1$s hat dich eingeladen" + \ No newline at end of file diff --git a/features/invitelist/impl/src/main/res/values-es/translations.xml b/features/invitelist/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..49c32a9e49 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s te invitó." + \ No newline at end of file diff --git a/features/invitelist/impl/src/main/res/values-it/translations.xml b/features/invitelist/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..c0eccfcb7c --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s ti ha invitato" + \ No newline at end of file diff --git a/features/invitelist/impl/src/main/res/values-ro/translations.xml b/features/invitelist/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..026485d102 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,9 @@ + + + "Sigur doriți să refuzați alăturarea la %1$s?" + "Refuzați invitația" + "Sigur doriți să refuzați conversațiile cu %1$s?" + "Refuzați conversația" + "Nicio invitație" + "%1$s v-a invitat" + \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..061f3453df --- /dev/null +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Wie lautet die Adresse deines Servers?" + "Willkommen zurück!" + "Passwort" + "Benutzername" + \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index a299083994..284527c2f5 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -4,17 +4,17 @@ "Este servidor no soporta sliding sync." "Dirección del homeserver" "Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s" - "Continuar" "¿Cuál es la dirección de tu servidor?" - "Selecciona tu servidor" "Esta cuenta ha sido desactivada." "Usuario y/o contraseña incorrectos" "Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'" "El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver." "Introduce tus datos" - "Contraseña" "Donde viven tus conversaciones" - "Continuar" "¡Hola de nuevo!" + "Continuar" + "Selecciona tu servidor" + "Contraseña" + "Continuar" "Usuario" \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml index 429f156883..b11875a18e 100644 --- a/features/login/impl/src/main/res/values-it/translations.xml +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -4,17 +4,17 @@ "Questo server attualmente non supporta la sincronizzazione scorrevole." "URL dell\'homeserver" "Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s" - "Continua" "Qual è l\'indirizzo del tuo server?" - "Seleziona il tuo server" "Questo profilo è stato disattivato." "Nome utente e/o password errati" "Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'" "L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver." "Inserisci i tuoi dati" - "Password" "Dove vivono le tue conversazioni" - "Continua" "Bentornato!" + "Continua" + "Seleziona il tuo server" + "Password" + "Continua" "Nome utente" \ No newline at end of file diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index 2b5cce6829..349e3ddc04 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -4,17 +4,17 @@ "Momentan acest server nu oferă suport pentru sliding sync." "Adresa URL a homeserver-ului" "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" - "Continuați" "Care este adresa serverului dumneavoastră?" - "Selectați serverul" "Acest cont a fost dezactivat." "Utilizator și/sau parolă incorecte" "Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”" "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver." "Introduceți detaliile" - "Parolă" "Locul unde trăiesc conversațiile tale" - "Continuați" "Bine ați revenit!" + "Continuați" + "Selectați serverul" + "Parola" + "Continuați" "Utilizator" \ No newline at end of file diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 82ea22e61b..6b0ecee43d 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -4,17 +4,17 @@ "This server currently doesn’t support sliding sync." "Homeserver URL" "You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s" - "Continue" "What is the address of your server?" - "Select your server" "This account has been deactivated." "Incorrect username and/or password" "This is not a valid user identifier. Expected format: ‘@user:homeserver.org’" "The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver." "Enter your details" - "Password" "Where your conversations live" - "Continue" "Welcome back!" + "Continue" + "Select your server" + "Password" + "Continue" "Username" \ No newline at end of file diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..9fd4f6b083 --- /dev/null +++ b/features/logout/api/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Abmelden" + "Abmelden" + "Abmeldung läuft…" + "Abmelden" + \ No newline at end of file diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 9b43d41d4d..3bd73d5ed2 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(projects.libraries.textcomposer) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.mediapickers) + implementation(projects.libraries.featureflag.api) implementation(projects.features.networkmonitor.api) implementation(libs.coil.compose) implementation(libs.datetime) @@ -55,6 +57,8 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.tests.testutils) + testImplementation(projects.libraries.featureflag.test) androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) diff --git a/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt b/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt deleted file mode 100644 index 97ef4f5a3b..0000000000 --- a/features/messages/impl/src/androidTest/kotlin/io/element/android/features/messages/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index dc71d1d834..c2becf71cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -102,6 +102,7 @@ private fun SheetContent( // Crashes if sheetContent size is zero Box(modifier = modifier.size(1.dp)) } + is ActionListState.Target.Success -> { val actions = target.actions LazyColumn( @@ -146,5 +147,11 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st @Composable private fun ContentToPreview(state: ActionListState) { - SheetContent(state) + ActionListView( + state = state, + modalBottomSheetState = ModalBottomSheetState( + initialValue = ModalBottomSheetValue.Expanded + ), + onActionSelected = { _, _ -> } + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt index 1da1188ee4..610f6e1ec0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt @@ -24,4 +24,6 @@ sealed interface MessageComposerEvents { object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents data class UpdateText(val text: CharSequence) : MessageComposerEvents + + object TakePhoto : MessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt index a5cc266e02..603776e518 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt @@ -21,23 +21,37 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.data.StableCharSequence 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.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class MessageComposerPresenter @Inject constructor( private val appCoroutineScope: CoroutineScope, - private val room: MatrixRoom + private val room: MatrixRoom, + private val mediaPickerProvider: PickerProvider, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable 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 { mutableStateOf(false) } @@ -63,9 +77,14 @@ class MessageComposerPresenter @Inject constructor( text.value = "".toStableCharSequence() composerMode.setToNormal() } + is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode - } + MessageComposerEvents.TakePhoto -> localCoroutineScope.launch { + if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) { + cameraPhotoPicker.launch() + } + }} } return MessageComposerState( @@ -92,6 +111,7 @@ class MessageComposerPresenter @Inject constructor( capturedMode.eventId, text ) + is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Reply -> room.replyMessage( capturedMode.eventId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt index e94ebb0a2a..ebdfca6f9e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt @@ -53,6 +53,9 @@ fun MessageComposerView( composerMode = state.mode, onCloseSpecialMode = ::onCloseSpecialMode, onComposerTextChange = ::onComposerTextChange, + onAddAttachment = { + state.eventSink(MessageComposerEvents.TakePhoto) + }, composerCanSendMessage = state.isSendButtonVisible, composerText = state.text?.charSequence?.toString(), isInDarkMode = !ElementTheme.colors.isLight, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 1d931c76bb..9418e59edc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -80,13 +80,6 @@ class TimelinePresenter @Inject constructor( .launchIn(this) } - DisposableEffect(Unit) { - timeline.initialize() - onDispose { - timeline.dispose() - } - } - return TimelineState( highlightedEventId = highlightedEventId.value, paginationState = paginationState.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index f252494df6..11a3985c9d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -99,11 +99,11 @@ fun TimelineView( itemsIndexed( items = state.timelineItems, contentType = { _, timelineItem -> timelineItem.contentType() }, - key = { _, timelineItem -> timelineItem.key() }, + key = { _, timelineItem -> timelineItem.identifier() }, ) { index, timelineItem -> TimelineItemRow( timelineItem = timelineItem, - isHighlighted = timelineItem.key() == state.highlightedEventId?.value, + isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value, onClick = onMessageClicked, 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 fun TimelineItemRow( timelineItem: TimelineItem, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt index c2f3b33667..9aa3ab5e02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt @@ -25,7 +25,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList id } + fun contentType(): String = when (this) { + is Event -> content.type + is Virtual -> model.type + } + @Immutable data class Virtual( val id: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt index 67fefb665e..a059c9b275 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -21,4 +21,6 @@ import org.jsoup.nodes.Document data class TimelineItemEmoteContent( override val body: String, override val htmlDocument: Document? -) : TimelineItemTextBasedContent +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemEmoteContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt index b42c2ac535..ff1bb36faf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt @@ -20,4 +20,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry data class TimelineItemEncryptedContent( val data: UnableToDecryptContent.Data -) : TimelineItemEventContent +) : TimelineItemEventContent { + override val type: String = "TimelineItemEncryptedContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 7e166dba97..233f51a5a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.runtime.Immutable @Immutable -sealed interface TimelineItemEventContent +sealed interface TimelineItemEventContent { + val type: String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 3f00a3c183..d6069e2020 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -23,4 +23,6 @@ data class TimelineItemImageContent( val mediaRequestData: MediaRequestData, val blurhash: String?, val aspectRatio: Float -) : TimelineItemEventContent +) : TimelineItemEventContent{ + override val type: String = "TimelineItemImageContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt index 6c647158c4..7974f188a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -21,4 +21,6 @@ import org.jsoup.nodes.Document data class TimelineItemNoticeContent( override val body: String, override val htmlDocument: Document? -) : TimelineItemTextBasedContent +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemNoticeContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt index de98b22bbb..7a8edae953 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.event -object TimelineItemRedactedContent : TimelineItemEventContent +object TimelineItemRedactedContent : TimelineItemEventContent{ + override val type: String = "TimelineItemRedactedContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt index 6cbb0ccd08..993275e627 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -21,4 +21,6 @@ import org.jsoup.nodes.Document data class TimelineItemTextContent( override val body: String, override val htmlDocument: Document? -) : TimelineItemTextBasedContent +) : TimelineItemTextBasedContent{ + override val type: String = "TimelineItemTextContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt index eb79b29f79..44aeb93e3b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.event -object TimelineItemUnknownContent : TimelineItemEventContent +object TimelineItemUnknownContent : TimelineItemEventContent { + override val type: String = "TimelineItemUnknownContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt index b4c1235f8f..54e95b7294 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt @@ -18,4 +18,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual data class TimelineItemDaySeparatorModel( val formattedDate: String -) : TimelineItemVirtualModel +) : TimelineItemVirtualModel { + override val type: String = "TimelineItemDaySeparatorModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt index 4870177a84..9cc7280b07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual -object TimelineItemLoadingModel : TimelineItemVirtualModel +object TimelineItemLoadingModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemLoadingModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt index 247cd58212..0b8e3fc0e5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual -object TimelineItemReadMarkerModel : TimelineItemVirtualModel +object TimelineItemReadMarkerModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemReadMarkerModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt index ab0ec4fdf8..8c1afea886 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual -object TimelineItemTimelineStartModel : TimelineItemVirtualModel +object TimelineItemTimelineStartModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemTimelineStartModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt index 6d023bf748..8b4fe44744 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt @@ -16,4 +16,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual -object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel +object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemUnknownVirtualModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt index a7911867f9..d6c3529ab4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt @@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual import androidx.compose.runtime.Immutable @Immutable -sealed interface TimelineItemVirtualModel +sealed interface TimelineItemVirtualModel { + val type: String +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index a7bc112174..aa916f1e79 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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.timeline.TimelinePresenter 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.test.AN_EVENT_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.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -129,9 +131,10 @@ class MessagesPresenterTest { ): MessagesPresenter { val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, - room = matrixRoom + room = matrixRoom, + mediaPickerProvider = PickerProvider(isInTest = true), + featureFlagService = FakeFeatureFlagService(), ) - val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 404e50a3bd..2d4ee3842c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.messages.fixtures 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.TimelineItemVirtualFactory import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi internal fun aTimelineItemsFactory() = TimelineItemsFactory( dispatchers = testCoroutineDispatchers(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 105d2ce31a..b18e9631ef 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -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.MessageComposerState 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.AN_EVENT_ID 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_USER_NAME import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediapickers.PickerProvider import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Test class MessageComposerPresenterTest { + + private val pickerProvider = PickerProvider(isInTest = true) + private val featureFlagService = FakeFeatureFlagService().apply { + runBlocking { + setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true) + } + } + @Test fun `present - initial state`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -60,7 +74,9 @@ class MessageComposerPresenterTest { fun `present - toggle fullscreen`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -79,7 +95,9 @@ class MessageComposerPresenterTest { fun `present - change message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -100,7 +118,9 @@ class MessageComposerPresenterTest { fun `present - change mode to edit`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -130,7 +150,9 @@ class MessageComposerPresenterTest { fun `present - change mode to reply`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -150,7 +172,9 @@ class MessageComposerPresenterTest { fun `present - change mode to quote`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -170,7 +194,9 @@ class MessageComposerPresenterTest { fun `present - send message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom() + FakeMatrixRoom(), + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -192,7 +218,9 @@ class MessageComposerPresenterTest { val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, - fakeMatrixRoom + fakeMatrixRoom, + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -223,7 +251,9 @@ class MessageComposerPresenterTest { val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, - fakeMatrixRoom + fakeMatrixRoom, + pickerProvider, + featureFlagService, ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -248,6 +278,25 @@ class MessageComposerPresenterTest { 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) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 24e6f77d32..41f37158d9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -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 fun `present - load more`() = runTest { val presenter = TimelinePresenter( diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt index ba4d6c2775..25819c6eb3 100644 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt @@ -71,8 +71,8 @@ class NetworkMonitorImpl @Inject constructor( private fun listenToConnectionChanges() { val request = NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) +// .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) +// .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() connectivityManager.registerNetworkCallback(request, callback) diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..82e01aa522 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,4 @@ + + + "Sei in deinem Element" + \ No newline at end of file diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..e18f43d1de --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,6 @@ + + + "Beschreibe den Fehler…" + "Absturzprotokolle senden" + "Bildschirmfoto senden" + \ No newline at end of file diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index dc840b038f..41f117d6a4 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.features.userlist.impl) testImplementation(projects.features.userlist.test) + testImplementation(projects.tests.testutils) ksp(libs.showkase.processor) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/blockuser/BlockUserSection.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/blockuser/BlockUserSection.kt new file mode 100644 index 0000000000..49daa15af3 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/blockuser/BlockUserSection.kt @@ -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 + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index f86b9952a8..f3a0ef9001 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -59,7 +59,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( object RoomMemberList : NavTarget @Parcelize - data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget + data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -74,14 +74,14 @@ class RoomDetailsFlowNode @AssistedInject constructor( } NavTarget.RoomMemberList -> { val roomMemberListCallback = object : RoomMemberListNode.Callback { - override fun openRoomMemberDetails(roomMember: RoomMember) { - backstack.push(NavTarget.RoomMemberDetails(roomMember)) + override fun openRoomMemberDetails(roomMemberId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(roomMemberId)) } } createNode(buildContext, listOf(roomMemberListCallback)) } is NavTarget.RoomMemberDetails -> { - createNode(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember))) + createNode(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId))) } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 46e76a5f8a..37110e5192 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -63,7 +63,10 @@ class RoomDetailsNode @AssistedInject constructor( activityResultLauncher = null, chooserTitle = context.getString(R.string.screen_room_details_share_room_title), 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) { val context = LocalContext.current val state = presenter.present() + + fun onShareRoom() { + this.onShareRoom(context) + } + + fun onShareMember(roomMember: RoomMember) { + this.onShareMember(context, roomMember) + } + RoomDetailsView( state = state, modifier = modifier, - goBack = { navigateUp() }, - onShareRoom = { onShareRoom(context) }, - onShareMember = { onShareMember(context, it) }, + goBack = this::navigateUp, + onShareRoom = ::onShareRoom, + onShareMember = ::onShareMember, openRoomMemberList = ::openRoomMemberList, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 45e978b80a..8f96487583 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -18,78 +18,70 @@ package io.element.android.features.roomdetails.impl import androidx.compose.runtime.Composable 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.mutableStateOf import androidx.compose.runtime.remember 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.Presenter -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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 kotlinx.coroutines.Dispatchers +import io.element.android.libraries.matrix.ui.room.getDirectRoomMember +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import javax.inject.Inject class RoomDetailsPresenter @Inject constructor( - private val sessionId: SessionId, private val room: MatrixRoom, private val roomMembershipObserver: RoomMembershipObserver, + private val coroutineDispatchers: CoroutineDispatchers, + private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, ) : Presenter { @Composable override fun present(): RoomDetailsState { val coroutineScope = rememberCoroutineScope() - var leaveRoomWarning by remember { + val leaveRoomWarning = remember { mutableStateOf(null) } - var error by remember { + val error = remember { mutableStateOf(null) } - - var memberCount: Async by remember { mutableStateOf(Async.Loading()) } LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - memberCount = runCatching { room.memberCount() } - .fold( - onSuccess = { Async.Success(it) }, - onFailure = { Async.Failure(it) } - ) - } + room.updateMembers() } - val dmMember = room.getDmMember() - val roomType = if (dmMember != null) { - RoomDetailsType.Dm(dmMember) - } else { - RoomDetailsType.Room - } + val membersState by room.membersStateFlow.collectAsState() + val memberCount by getMemberCount(membersState) + val dmMember by room.getDirectRoomMember(membersState) + val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) + val roomType = getRoomType(dmMember) fun handleEvents(event: RoomDetailsEvent) { when (event) { is RoomDetailsEvent.LeaveRoom -> { - if (event.needsConfirmation) { - leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount) - } else { - coroutineScope.launch(Dispatchers.IO) { - room.leave() - .onSuccess { - roomMembershipObserver.notifyUserLeftRoom(room.roomId) - }.onFailure { - error = RoomDetailsError.AlertGeneric - } - leaveRoomWarning = null - } - } + coroutineScope.leaveRoom( + needsConfirmation = event.needsConfirmation, + memberCount = memberCount, + leaveRoomWarning = leaveRoomWarning, + error = error, + ) } - is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null - RoomDetailsEvent.ClearError -> error = null + is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null + RoomDetailsEvent.ClearError -> error.value = null } } + val roomMemberDetailsState = roomMemberDetailsPresenter?.present() + return RoomDetailsState( roomId = room.roomId.value, roomName = room.name ?: room.displayName, @@ -98,10 +90,66 @@ class RoomDetailsPresenter @Inject constructor( roomTopic = room.topic, memberCount = memberCount, isEncrypted = room.isEncrypted, - displayLeaveRoomWarning = leaveRoomWarning, - error = error, - roomType = roomType, + displayLeaveRoomWarning = leaveRoomWarning.value, + error = error.value, + roomType = roomType.value, + roomMemberDetailsState = roomMemberDetailsState, eventSink = ::handleEvents, ) } + + @Composable + private fun roomMemberDetailsPresenter(dmMemberState: RoomMember?) = remember(dmMemberState) { + dmMemberState?.let { roomMember -> + roomMembersDetailsPresenterFactory.create(roomMember.userId) + } + } + + @Composable + private fun getRoomType(dmMember: RoomMember?): State = remember(dmMember) { + derivedStateOf { + if (dmMember != null) { + RoomDetailsType.Dm(dmMember) + } else { + RoomDetailsType.Room + } + } + } + + @Composable + private fun getMemberCount(membersState: MatrixRoomMembersState): State> { + 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, + leaveRoomWarning: MutableState, + error: MutableState, + ) = 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 + } + } } + + + + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index f8fed122de..173ba66ed0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -16,10 +16,8 @@ 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.isLoading - -import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMember data class RoomDetailsState( @@ -33,6 +31,7 @@ data class RoomDetailsState( val displayLeaveRoomWarning: LeaveRoomWarning?, val error: RoomDetailsError?, val roomType: RoomDetailsType, + val roomMemberDetailsState: RoomMemberDetailsState?, val eventSink: (RoomDetailsEvent) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 61b9d310af..d30ea15f4a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -71,5 +71,6 @@ fun aRoomDetailsState() = RoomDetailsState( displayLeaveRoomWarning = null, error = null, roomType = RoomDetailsType.Room, + roomMemberDetailsState = null, eventSink = {} ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index b9266cd8ca..dbee610dba 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -42,7 +42,8 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter 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.RoomMemberShareSection import io.element.android.libraries.architecture.Async @@ -116,7 +117,7 @@ fun RoomDetailsView( } if (state.roomType is RoomDetailsType.Room) { - val memberCount = (state.memberCount as? Async.Success)?.state + val memberCount = state.memberCount.dataOrNull() MembersSection( memberCount = memberCount, isLoading = state.memberCount.isLoading(), @@ -135,10 +136,11 @@ fun RoomDetailsView( }) } is RoomDetailsType.Dm -> { - BlockSection( - isBlocked = state.roomType.roomMember.isIgnored, - onToggleBlock = { /*TODO*/ } - ) + if (state.roomMemberDetailsState != null) { + val roomMemberState = state.roomMemberDetailsState + BlockUserSection(roomMemberState) + BlockUserDialogs(roomMemberState) + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt index 68f77b5821..2cd3b7eb08 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt @@ -20,15 +20,14 @@ import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module 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.details.RoomMemberDetailsPresenter import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.di.RoomScope 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.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import javax.inject.Named @Module @@ -44,22 +43,14 @@ interface RoomMemberBindsModule { @ContributesTo(RoomScope::class) object RoomMemberProvidesModule { - @Provides - fun provideRoomDetailsPresenter( - matrixClient: MatrixClient, - room: MatrixRoom, - roomMembershipObserver: RoomMembershipObserver, - ): RoomDetailsPresenter { - return RoomDetailsPresenter(matrixClient.sessionId, room, roomMembershipObserver) - } - @Provides fun provideRoomMemberDetailsPresenterFactory( + matrixClient: MatrixClient, room: MatrixRoom, ): RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory { - override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { - return RoomMemberDetailsPresenter(room, roomMember) + override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId) } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt similarity index 56% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt index 60b0b7cf4b..4534842cac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt @@ -14,15 +14,10 @@ * 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 kotlinx.coroutines.test.UnconfinedTestDispatcher +import io.element.android.libraries.matrix.ui.model.MatrixUser -// TODO Move to common module to reuse -internal fun testCoroutineDispatchers() = CoroutineDispatchers( - io = UnconfinedTestDispatcher(), - computation = UnconfinedTestDispatcher(), - main = UnconfinedTestDispatcher(), - diffUpdateDispatcher = UnconfinedTestDispatcher(), -) +sealed interface RoomMemberListEvents { + data class SelectUser(val user: MatrixUser) : RoomMemberListEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt index fafe0ede99..5b1e7a72e0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -26,31 +26,25 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode 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.ui.model.MatrixUser -import timber.log.Timber @ContributesNode(RoomScope::class) class RoomMemberListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val room: MatrixRoom, private val presenter: RoomMemberListPresenter, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun openRoomMemberDetails(roomMember: RoomMember) + fun openRoomMemberDetails(roomMemberId: UserId) } private val callbacks = plugins() - private fun onUserSelected(matrixUser: MatrixUser) { - val member = room.getMember(matrixUser.id) - if (member != null) { - callbacks.forEach { it.openRoomMemberDetails(member) } - } else { - Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}") + private fun openRoomMemberDetails(roomMemberId: UserId) { + callbacks.forEach { + it.openRoomMemberDetails(roomMemberId) } } @@ -61,7 +55,7 @@ class RoomMemberListNode @AssistedInject constructor( state = state, modifier = modifier, onBackPressed = { navigateUp() }, - onUserSelected = ::onUserSelected, + onMemberSelected = this::openRoomMemberDetails, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 8841bd9d5e..8d66d60bc2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -27,10 +27,11 @@ import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.libraries.architecture.Async 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Named @@ -39,6 +40,8 @@ class RoomMemberListPresenter @Inject constructor( private val userListPresenterFactory: UserListPresenter.Factory, @Named("RoomMembers") private val userListDataSource: UserListDataSource, private val userListDataStore: UserListDataStore, + private val room: MatrixRoom, + private val coroutineDispatchers: CoroutineDispatchers, ) : Presenter { private val userListPresenter by lazy { @@ -53,14 +56,16 @@ class RoomMemberListPresenter @Inject constructor( override fun present(): RoomMemberListState { val userListState = userListPresenter.present() val allUsers = remember { mutableStateOf>>(Async.Loading()) } + LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { + withContext(coroutineDispatchers.io) { allUsers.value = Async.Success(userListDataSource.search("").toImmutableList()) } } + return RoomMemberListState( allUsers = allUsers.value, - userListState = userListState + userListState = userListState, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index f5e5bd3efb..28885006b1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -18,11 +18,11 @@ package io.element.android.features.roomdetails.impl.members import io.element.android.features.userlist.api.UserListState 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 kotlinx.collections.immutable.ImmutableList data class RoomMemberListState( val allUsers: Async>, val userListState: UserListState, -// val eventSink: (AddPeopleEvents) -> Unit, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index f356e203f2..99c6a9299a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -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.Scaffold 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 @OptIn(ExperimentalMaterial3Api::class) @@ -59,8 +60,13 @@ fun RoomMemberListView( state: RoomMemberListState, modifier: Modifier = Modifier, onBackPressed: () -> Unit = {}, - onUserSelected: (MatrixUser) -> Unit = {}, + onMemberSelected: (UserId) -> Unit = {}, ) { + + fun onUserSelected(user: MatrixUser) { + onMemberSelected(user.id) + } + Scaffold( topBar = { if (!state.userListState.isSearchActive) { @@ -76,7 +82,7 @@ fun RoomMemberListView( ) { UserListView( state = state.userListState, - onUserSelected = onUserSelected, + onUserSelected = ::onUserSelected, ) if (!state.userListState.isSearchActive) { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt index dc0008d2a3..e0559f3caf 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt @@ -18,26 +18,39 @@ package io.element.android.features.roomdetails.impl.members import io.element.android.features.userlist.api.UserListDataSource 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.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.roomMembers 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 class RoomUserListDataSource @Inject constructor( - private val room: MatrixRoom + private val room: MatrixRoom, + private val coroutineDispatchers: CoroutineDispatchers, ) : UserListDataSource { - override suspend fun search(query: String): List { - return room.members().filter { member -> - if (query.isBlank()) { - true - } else { + override suspend fun search(query: String): List = withContext(coroutineDispatchers.io) { + val roomMembers = room.membersStateFlow + .dropWhile { it !is MatrixRoomMembersState.Ready } + .first() + .roomMembers() + .orEmpty() + val filteredMembers = if (query.isBlank()) { + roomMembers + } else { + roomMembers.filter { member -> member.userId.value.contains(query, ignoreCase = true) || member.displayName?.contains(query, ignoreCase = true).orFalse() } - }.map(::mapMemberToMatrixUser) + } + filteredMembers.map(::mapMemberToMatrixUser) } override suspend fun getProfile(userId: UserId): MatrixUser? { @@ -55,5 +68,4 @@ class RoomUserListDataSource @Inject constructor( ) ) } - } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt index 2c74caa8fd..5848561f3e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt @@ -16,4 +16,8 @@ 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 +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index 5cd2544537..7fd4dd3876 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -32,7 +32,6 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope 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.room.RoomMember import timber.log.Timber import io.element.android.libraries.androidutils.R as AndroidUtilsR @@ -44,19 +43,18 @@ class RoomMemberDetailsNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { data class Inputs( - val member: RoomMember, + val roomMemberId: UserId, ) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.member) + private val presenter = presenterFactory.create(inputs.roomMemberId) @Composable override fun View(modifier: Modifier) { - val context = LocalContext.current fun onShareUser() { - val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.member.userId) + val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId) permalinkResult.onSuccess { permalink -> startSharePlainTextIntent( context = context, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index de24b5ee1b..594152e241 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -17,49 +17,108 @@ package io.element.android.features.roomdetails.impl.members.details import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf 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.AssistedInject +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog 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.RoomMember +import io.element.android.libraries.matrix.ui.room.getRoomMember +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch class RoomMemberDetailsPresenter @AssistedInject constructor( + private val client: MatrixClient, private val room: MatrixRoom, - @Assisted private val roomMember: RoomMember, + @Assisted private val roomMemberId: UserId, ) : Presenter { interface Factory { - fun create(roomMember: RoomMember): RoomMemberDetailsPresenter + fun create(roomMemberId: UserId): RoomMemberDetailsPresenter } @Composable override fun present(): RoomMemberDetailsState { + val coroutineScope = rememberCoroutineScope() + var confirmationDialog by remember { mutableStateOf(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) { -// when (event) { -// } -// } + fun handleEvents(event: RoomMemberDetailsEvents) { + 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) { - room.userDisplayName(roomMember.userId).onSuccess { displayName -> + val userName by produceState(initialValue = roomMember?.displayName) { + room.userDisplayName(roomMemberId).onSuccess { displayName -> if (displayName != null) value = displayName } } - val userAvatar by produceState(initialValue = roomMember.avatarUrl) { - room.userAvatarUrl(roomMember.userId).onSuccess { avatarUrl -> + val userAvatar by produceState(initialValue = roomMember?.avatarUrl) { + room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl -> if (avatarUrl != null) value = avatarUrl } } return RoomMemberDetailsState( - userId = roomMember.userId.value, + userId = roomMemberId.value, userName = userName, avatarUrl = userAvatar, - isBlocked = roomMember.isIgnored, -// eventSink = ::handleEvents + isBlocked = isBlocked.value, + displayConfirmationDialog = confirmationDialog, + isCurrentUser = roomMember?.userId == client.sessionId, + eventSink = ::handleEvents ) } + + private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState) = launch { + client.ignoreUser(userId) + .map { + isBlockedState.value = true + room.updateMembers() + } + + } + + private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState) = launch { + client.unignoreUser(userId) + .map { + isBlockedState.value = false + room.updateMembers() + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt index d9e3f949e7..0a2895db09 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt @@ -21,5 +21,11 @@ data class RoomMemberDetailsState( val userName: String?, val avatarUrl: String?, val isBlocked: Boolean, -// val eventSink: (RoomMemberDetailsEvents) -> Unit -) + val displayConfirmationDialog: ConfirmationDialog? = null, + val isCurrentUser: Boolean, + val eventSink: (RoomMemberDetailsEvents) -> Unit +) { + enum class ConfirmationDialog { + Block, Unblock + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt index c719ab7a26..d8e7ce5ad3 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -24,6 +24,8 @@ open class RoomMemberDetailsStateProvider : PreviewParameterProvider 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 @Composable fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..7581b585f1 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,15 @@ + + + + "1 Person" + "%1$d Personen" + + "Raum teilen" + "Blockieren" + "Nutzer blockieren" + "Blockierung aufheben" + "Nutzer entblockieren" + "Raum verlassen" + "Sicherheit" + "Thema" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml index ba4327000b..58c486d6c3 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -4,18 +4,18 @@ "Una persona" "%1$d personas" + "Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos." + "Cifrado de mensajes activado" + "Invitar a otras personas" + "Compartir sala" "Bloquear" - "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puedes revertir esta acción en cualquier momento." + "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento." "Bloquear usuario" "Desbloquear" "Al desbloquear al usuario, podrás volver a ver todos sus mensajes." "Desbloquear usuario" - "Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos." - "Cifrado de mensajes activado" - "Invitar a otras personas" "Salir de la sala" "Personas" "Seguridad" - "Compartir sala" "Tema" \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml index 9a980b79a9..a2e61a329c 100644 --- a/features/roomdetails/impl/src/main/res/values-it/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -4,18 +4,18 @@ "1 persona" "%1$d persone" + "I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli." + "Crittografia messaggi abilitata" + "Invita persone" + "Condividi stanza" "Blocca" - "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti i loro messaggi saranno nascosti. Potrai annullare questa azione in qualsiasi momento." + "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." "Blocca utente" "Sblocca" "Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi." "Sblocca utente" - "I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli." - "Crittografia messaggi abilitata" - "Invita persone" "Esci dalla stanza" "Persone" "Sicurezza" - "Condividi stanza" "Oggetto" \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml index db6777fb7f..3525b06d8e 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -5,18 +5,18 @@ "%1$d persoane" + "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." + "Criptarea mesajelor este activată" + "Invitați persoane" + "Partajați camera" "Blocați" "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." "Blocați utilizatorul" "Deblocați" "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." "Deblocați utilizatorul" - "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." - "Criptarea mesajelor este activată" - "Invitați persoane" "Părăsiți camera" "Persoane" "Securitate" - "Partajați camera" "Subiect" \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index f63757a8e3..584f4322d3 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -4,18 +4,18 @@ "1 person" "%1$d people" + "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." + "Message encryption enabled" + "Invite people" + "Share room" "Block" "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime." "Block user" "Unblock" "On unblocking the user, you will be able to see all messages by them again." "Unblock user" - "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." - "Message encryption enabled" - "Invite people" "Leave room" "People" "Security" - "Share room" "Topic" \ No newline at end of file diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 66e74cd5cc..2cc6d0ec24 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -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.RoomDetailsEvent 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.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.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.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_SESSION_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.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -46,11 +54,21 @@ import org.junit.Test class RoomDetailsPresenterTests { 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 fun `present - initial state is created from room info`() = runTest { val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -59,7 +77,7 @@ class RoomDetailsPresenterTests { Truth.assertThat(initialState.roomName).isEqualTo(room.name) Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) 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) cancelAndIgnoreRemainingEvents() @@ -68,23 +86,42 @@ class RoomDetailsPresenterTests { @Test fun `present - room member count is calculated asynchronously`() = runTest { + val error = RuntimeException() 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) { presenter.present() }.test { + room.givenRoomMembersState(MatrixRoomMembersState.Unknown) val initialState = awaitItem() - Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) + Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized) - val finalState = awaitItem() - Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0)) + room.givenRoomMembersState(MatrixRoomMembersState.Pending(null)) + 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 fun `present - initial state with no room name`() = runTest { val room = aMatrixRoom(name = null) - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -96,16 +133,22 @@ class RoomDetailsPresenterTests { } @Test - fun `present - can handle error while fetching member count`() = runTest { - val room = aMatrixRoom(name = null).apply { - givenFetchMemberResult(Result.failure(Throwable())) + fun `present - initial state with DM member sets custom DM roomType`() = runTest { + val myRoomMember = aRoomMember(A_SESSION_ID) + 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) { presenter.present() }.test { - skipItems(1) - Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java) + val initialState = awaitItem() + Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember)) cancelAndIgnoreRemainingEvents() } @@ -113,15 +156,14 @@ class RoomDetailsPresenterTests { @Test fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { - val room = aMatrixRoom(isPublic = false) - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val room = aMatrixRoom(isPublic = false).apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) + } + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - // Allow room member count to load - skipItems(1) - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) val confirmationState = awaitItem() Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) @@ -130,15 +172,14 @@ class RoomDetailsPresenterTests { @Test fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { - val room = aMatrixRoom(members = listOf(aRoomMember())) - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val room = aMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember()))) + } + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - // Allow room member count to load - skipItems(1) - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) val confirmationState = awaitItem() Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) @@ -147,15 +188,14 @@ class RoomDetailsPresenterTests { @Test fun `present - Leave with confirmation shows a generic warning`() = runTest { - val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val room = aMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) + } + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - // Allow room member count to load - skipItems(1) - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) val confirmationState = awaitItem() Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) @@ -164,15 +204,14 @@ class RoomDetailsPresenterTests { @Test fun `present - Leave without confirmation leaves the room`() = runTest { - val room = aMatrixRoom() - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val room = aMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) + } + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - // Allow room member count to load - skipItems(1) - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) cancelAndIgnoreRemainingEvents() @@ -189,14 +228,11 @@ class RoomDetailsPresenterTests { val room = aMatrixRoom().apply { givenLeaveRoomError(Throwable()) } - val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) + val presenter = aRoomDetailsPresenter(room) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - // Allow room member count to load - skipItems(1) - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) val errorState = awaitItem() Truth.assertThat(errorState.error).isNotNull() @@ -206,24 +242,28 @@ class RoomDetailsPresenterTests { } } +fun aMatrixClient( + sessionId: SessionId = A_SESSION_ID, +) = FakeMatrixClient() + fun aMatrixRoom( roomId: RoomId = A_ROOM_ID, name: String? = A_ROOM_NAME, displayName: String = "A fallback display name", topic: String? = "A topic", avatarUrl: String? = "https://matrix.org/avatar.jpg", - members: List = emptyList(), isEncrypted: Boolean = true, isPublic: Boolean = true, + isDirect: Boolean = false, ) = FakeMatrixRoom( roomId = roomId, name = name, displayName = displayName, topic = topic, avatarUrl = avatarUrl, - members = members, isEncrypted = isEncrypted, isPublic = isPublic, + isDirect = isDirect, ) fun aRoomMember( diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt index 373ebbb347..a5798381c0 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt @@ -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.test.FakeUserListDataSource 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.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import okhttp3.internal.toImmutableList import org.junit.Test @@ -38,6 +42,8 @@ import org.junit.Test @ExperimentalCoroutinesApi class RoomMemberListPresenterTests { + private val testCoroutineDispatchers = testCoroutineDispatchers() + @Test fun `present - search is done automatically on start, but is async`() = runTest { val searchResult = listOf(aMatrixUser()) @@ -52,7 +58,14 @@ class RoomMemberListPresenterTests { userListDataStore: 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) { presenter.present() }.test { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt index 68de573fae..13eb28ca85 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt @@ -20,9 +20,13 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test 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.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.RoomMemberDetailsState +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -30,14 +34,17 @@ import org.junit.Test @ExperimentalCoroutinesApi class RoomMemberDetailsPresenterTests { + private val matrixClient = aMatrixClient() + @Test fun `present - returns the room member's data, then updates it if needed`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") val room = aMatrixRoom().apply { givenUserDisplayNameResult(Result.success("A custom name")) givenUserAvatarUrlResult(Result.success("A custom avatar")) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val roomMember = aRoomMember(displayName = "Alice") - val presenter = RoomMemberDetailsPresenter(room, roomMember) + val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -55,12 +62,13 @@ class RoomMemberDetailsPresenterTests { @Test fun `present - will recover when retrieving room member details fails`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") val room = aMatrixRoom().apply { givenUserDisplayNameResult(Result.failure(Throwable())) givenUserAvatarUrlResult(Result.failure(Throwable())) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val roomMember = aRoomMember(displayName = "Alice") - val presenter = RoomMemberDetailsPresenter(room, roomMember) + val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -74,12 +82,13 @@ class RoomMemberDetailsPresenterTests { @Test fun `present - will fallback to original data if the updated data is null`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") val room = aMatrixRoom().apply { givenUserDisplayNameResult(Result.success(null)) givenUserAvatarUrlResult(Result.success(null)) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val roomMember = aRoomMember(displayName = "Alice") - val presenter = RoomMemberDetailsPresenter(room, roomMember) + val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -90,4 +99,63 @@ class RoomMemberDetailsPresenterTests { 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() + } + } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index 299c670eb4..684342bee8 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -20,4 +20,5 @@ sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents + object ToggleSearchResults : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 38c80734e2..f366c25326 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -79,6 +79,7 @@ class RoomListPresenter @Inject constructor( Timber.v("RoomSummaries size = ${roomSummaries.size}") + val mappedRoomSummaries: MutableState> = remember { mutableStateOf(persistentListOf()) } val filteredRoomSummaries: MutableState> = remember { mutableStateOf(persistentListOf()) } @@ -101,41 +102,51 @@ class RoomListPresenter @Inject constructor( derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } } + var displaySearchResults by rememberSaveable { mutableStateOf(false) } + fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true + RoomListEvents.ToggleSearchResults -> { + if (displaySearchResults) { + filter = "" + } + displaySearchResults =! displaySearchResults + } } } 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) return RoomListState( matrixUser = matrixUser.value, - roomList = filteredRoomSummaries.value, + roomList = mappedRoomSummaries.value, filter = filter, + filteredRoomList = filteredRoomSummaries.value, displayVerificationPrompt = displayVerificationPrompt, snackbarMessage = snackbarMessage, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, displayInvites = invites.isNotEmpty(), + displaySearchResults = displaySearchResults, eventSink = ::handleEvents ) } - private suspend fun updateFilteredRoomSummaries(roomSummaries: List?, filter: String): ImmutableList { - if (roomSummaries.isNullOrEmpty()) { - return RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList() - } - val mappedRoomSummaries = mapRoomSummaries(roomSummaries) - return if (filter.isEmpty()) { - mappedRoomSummaries - } else { - mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) } + private fun updateFilteredRoomSummaries(mappedRoomSummaries: ImmutableList, filter: String): ImmutableList { + return when { + filter.isEmpty() -> emptyList() + else -> mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) } }.toImmutableList() } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 185c7c94a9..6fd629d67f 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -26,10 +26,12 @@ import kotlinx.collections.immutable.ImmutableList data class RoomListState( val matrixUser: MatrixUser?, val roomList: ImmutableList, - val filter: String, + val filter: String?, + val filteredRoomList: ImmutableList, val displayVerificationPrompt: Boolean, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, val displayInvites: Boolean, + val displaySearchResults: Boolean, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 1dfa943660..a95456b6a4 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -36,6 +36,8 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), aRoomListState().copy(hasNetworkConnection = false), 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")), roomList = aRoomListRoomSummaryList(), filter = "filter", + filteredRoomList = aRoomListRoomSummaryList(), hasNetworkConnection = true, snackbarMessage = null, displayVerificationPrompt = false, displayInvites = false, + displaySearchResults = false, eventSink = {} ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 8c591f2c2b..cd42548007 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -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.RoomSummaryRow 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.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -91,15 +93,27 @@ fun RoomListView( onCreateRoomClicked: () -> Unit = {}, onInvitesClicked: () -> Unit = {}, ) { - RoomListContent( - state = state, - modifier = modifier, - onRoomClicked = onRoomClicked, - onOpenSettings = onOpenSettings, - onVerifyClicked = onVerifyClicked, - onCreateRoomClicked = onCreateRoomClicked, - onInvitesClicked = onInvitesClicked, - ) + Column(modifier = modifier) { + ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) + Box { + RoomListContent( + state = state, + onRoomClicked = onRoomClicked, + onOpenSettings = onOpenSettings, + 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) @@ -163,16 +177,14 @@ fun RoomListContent( Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - Column { - ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) - RoomListTopBar( - matrixUser = state.matrixUser, - filter = state.filter, - onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, - onOpenSettings = onOpenSettings, - scrollBehavior = scrollBehavior, - ) - } + RoomListTopBar( + matrixUser = state.matrixUser, + areSearchResultsDisplayed = state.displaySearchResults, + onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, + onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, + onOpenSettings = onOpenSettings, + scrollBehavior = scrollBehavior, + ) }, content = { padding -> Column( @@ -306,7 +318,7 @@ internal fun PreviewRequestVerificationHeaderDark() { } } -private fun RoomListRoomSummary.contentType() = isPlaceholder +internal fun RoomListRoomSummary.contentType() = isPlaceholder @Preview @Composable @@ -322,3 +334,11 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl private fun ContentToPreview(state: RoomListState) { RoomListView(state) } + +@Preview +@Composable +internal fun RoomListSearchResultContentPreview() { + ElementPreviewLight { + RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {}) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index 1d1a4e7c10..11f0616e07 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -20,57 +20,42 @@ package io.element.android.features.roomlist.impl.components import androidx.activity.compose.BackHandler 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.filled.ArrowBack -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState 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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.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.ElementPreviewLight 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.MediumTopAppBar 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.matrix.api.core.UserId 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 +@OptIn(ExperimentalMaterial3Api::class) @Composable fun RoomListTopBar( matrixUser: MatrixUser?, - filter: String, + areSearchResultsDisplayed: Boolean, onFilterChanged: (String) -> Unit, + onToggleSearch: () -> Unit, onOpenSettings: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, modifier: Modifier = Modifier, @@ -79,124 +64,26 @@ fun RoomListTopBar( tag = "RoomListScreen", msg = "TopBar" ) - var searchWidgetStateIsOpened by rememberSaveable { mutableStateOf(false) } fun closeFilter() { onFilterChanged("") - searchWidgetStateIsOpened = false } - BackHandler(enabled = searchWidgetStateIsOpened) { + BackHandler(enabled = areSearchResultsDisplayed) { closeFilter() + onToggleSearch() } - if (searchWidgetStateIsOpened) { - SearchRoomListTopBar( - text = filter, - onFilterChanged = onFilterChanged, - onCloseClicked = ::closeFilter, - scrollBehavior = scrollBehavior, - 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()), + DefaultRoomListTopBar( + matrixUser = matrixUser, + onOpenSettings = onOpenSettings, + onSearchClicked = onToggleSearch, + scrollBehavior = scrollBehavior, + modifier = modifier, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DefaultRoomListTopBar( matrixUser: MatrixUser?, @@ -216,21 +103,19 @@ private fun DefaultRoomListTopBar( }, navigationIcon = { if (matrixUser != null) { - IconButton(onClick = {}) { - Avatar(matrixUser.avatarData) + IconButton( + modifier = Modifier.testTag(TestTags.homeScreenSettings), + onClick = onOpenSettings + ) { + Avatar(matrixUser.avatarData, contentDescription = stringResource(StringR.string.common_settings)) } } }, actions = { IconButton( - onClick = onSearchClicked + onClick = onSearchClicked, ) { - Icon(Icons.Default.Search, contentDescription = "search") - } - IconButton( - onClick = onOpenSettings - ) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + Icon(Icons.Default.Search, contentDescription = stringResource(StringR.string.action_search)) } }, scrollBehavior = scrollBehavior, @@ -246,6 +131,7 @@ internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { Default @Composable internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DefaultRoomListTopBarPreview() { DefaultRoomListTopBar( diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt new file mode 100644 index 0000000000..c2baca9a6b --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt @@ -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) + } + } + } + } +} diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..00b1431f00 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,40 @@ + + + "Alle Chats" + "(Avatar wurde ebenfalls geändert)" + "%1$s hat seinen Avatar geändert" + "Du hast deinen Avatar geändert" + "%1$s hat den Anzeigenamen von %2$s in %3$s geändert" + "Du hast deinen Anzeigenamen von %1$s in %2$s geändert" + "%1$s hat den Anzeigenamen entfernt (war %2$s)" + "Du hast deinen Anzeigenamen entfernt (war %1$s)" + "%1$s hat den Anzeigenamen auf %2$s gesetzt" + "Du hast deinen Anzeigenamen auf %1$s gesetzt" + "%1$s hat den Raum-Avatar geändert" + "Du hast den Raum-Avatar geändert" + "%1$s hat den Raum-Avatar entfernt" + "%1$s hat den Raum erstellt" + "Du hast den Raum erstellt" + "%1$s hat %2$s eingeladen" + "%1$s hat die Einladung angenommen" + "Du hast die Einladung angenommen" + "Du hast %1$s eingeladen" + "%1$s hat dich eingeladen" + "%1$s ist dem Raum beigetreten" + "Du bist dem Raum beigetreten" + "%1$s hat deine Beitrittsanfrage abgelehnt" + "%1$s hat den Raum verlassen" + "Du hast den Raum verlassen" + "%1$s hat den Raumnamen geändert in: %2$s" + "Sie haben den Raumnamen geändert in: %1$s" + "%1$s hat den Raumnamen entfernt" + "Du hast den Raumnamen entfernt" + "%1$s hat die Einladung abgelehnt" + "Du hast die Einladung abgelehnt" + "%1$s hat %2$s entfernt" + "Du hast %1$s entfernt" + "%1$s hat das Thema geändert zu: %2$s" + "Sie haben das Thema geändert zu: %1$s" + "%1$s hat das Raumthema entfernt" + "Du hast das Raumthema entfernt" + \ No newline at end of file diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index e28eb1e362..5dd73980f8 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -112,6 +112,8 @@ class RoomListPresenterTests { withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) val withFilterState = awaitItem() Truth.assertThat(withFilterState.filter).isEqualTo("t") + + cancelAndIgnoreRemainingEvents() } } @@ -168,17 +170,18 @@ class RoomListPresenterTests { val loadedState = awaitItem() // Test filtering with result loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) + skipItems(1) // Filter update val withNotFilteredRoomState = awaitItem() Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) - Truth.assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1) - Truth.assertThat(withNotFilteredRoomState.roomList.first()) + Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1) + Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first()) .isEqualTo(aRoomListRoomSummary) // Test filtering without result withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) skipItems(1) // Filter update val withFilteredRoomState = awaitItem() Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada") - Truth.assertThat(withFilteredRoomState.roomList).isEmpty() + Truth.assertThat(withFilteredRoomState.filteredRoomList).isEmpty() } } diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt index afe2d1ab3d..2cfd23eb61 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser interface UserListDataSource { + //TODO should probably have a flow suspend fun search(query: String): List suspend fun getProfile(userId: UserId): MatrixUser? } diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..e3817c2507 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ + + + "Emojis vergleichen" + "Beweise, dass du es bist, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen." + "Ich bin bereit" + "Warten auf Übereinstimmung" + "Sie stimmen nicht überein" + "Sie stimmen überein" + "Verifizierung abgebrochen" + \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml index 839c945e24..ccc656e845 100644 --- a/features/verifysession/impl/src/main/res/values-es/translations.xml +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -1,7 +1,6 @@ "Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó." - "Verificación cancelada" "Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión." "Comparar emojis" "Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza." @@ -9,11 +8,12 @@ "Abrir una sesión existente" "Reintentar la verificación" "Estoy listo" - "Comenzar" "Esperando a que coincida" "Compara los emoji, asegurándote de que aparecen en el mismo orden." "No coinciden" "Coinciden" "Acepta la solicitud para iniciar el proceso de verificación en tu otra sesión para continuar." "A la espera de aceptar la solicitud" + "Verificación cancelada" + "Comenzar" \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml index 3d8a46d581..1bf0e87ea9 100644 --- a/features/verifysession/impl/src/main/res/values-it/translations.xml +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -1,7 +1,6 @@ "C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata." - "Verifica annullata" "Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione." "Confronta le emoji" "La tua nuova sessione è ora verificata. Ha accesso ai tuoi messaggi crittografati e gli altri utenti la vedranno come attendibile." @@ -9,11 +8,12 @@ "Apri una sessione esistente" "Riprova la verifica" "Sono pronto" - "Inizia" "In attesa di un riscontro" "Confronta le emoji uniche, assicurandoti che appaiano nello stesso ordine." "Non corrispondono" "Corrispondono" "Accetta la richiesta di avviare il processo di verifica nell\'altra sessione per continuare." "In attesa di accettare la richiesta" + "Verifica annullata" + "Inizia" \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml index f2bade56fc..3ad0de6e56 100644 --- a/features/verifysession/impl/src/main/res/values-ro/translations.xml +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,6 @@ "Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă." - "Verificare anulată" "Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." "Comparați emoticoanele" "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar alți utilizatori vă vor vedea ca fiind de încredere." @@ -9,11 +8,12 @@ "Deschideți o sesiune existentă" "Reîncercați verificarea" "Sunt pregătit" - "Începeți" "Se așteaptă confirmarea" "Comparăți emoticoalene asigurându-vă că apar în aceeași ordine." "Nu se potrivesc" "Se potrivesc" "Acceptați solicitarea de a începe procesul de verificare în cealaltă sesiune pentru a continua." "Se așteptă acceptarea cererii" + "Verificare anulată" + "Începeți" \ No newline at end of file diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index fd81d104fb..c217f0d2a4 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -1,7 +1,6 @@ "Something doesn’t seem right. Either the request timed out or the request was denied." - "Verification cancelled" "Confirm that the emojis below match those shown on your other session." "Compare emojis" "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted." @@ -9,11 +8,12 @@ "Open an existing session" "Retry verification" "I am ready" - "Start" "Waiting to match" "Compare the unique emoji, ensuring they appear in the same order." "They don’t match" "They match" "Accept the request to start the verification process in your other session to continue." "Waiting to accept request" + "Verification cancelled" + "Start" \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 275145a865..cd16d4f022 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] # Project -android_gradle_plugin = "8.0.0" +android_gradle_plugin = "8.0.1" kotlin = "1.8.20" ksp = "1.8.20-1.0.11" molecule = "0.9.0" @@ -36,14 +36,14 @@ coil = "2.3.0" datetime = "0.4.0" serialization_json = "1.5.0" showkase = "1.0.0-beta17" -jsoup = "1.15.4" +jsoup = "1.16.1" appyx = "1.2.0" dependencycheck = "8.2.1" stem = "2.3.0" sqldelight = "1.5.5" # DI -dagger = "2.45" +dagger = "2.46" anvil = "2.4.5" # quality @@ -55,7 +55,7 @@ dependencygraph = "0.10" android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:31.5.0" +google_firebase_bom = "com.google.firebase:firebase-bom:32.0.0" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } @@ -128,11 +128,11 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.10" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.11" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.3" +sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlite = "androidx.sqlite:sqlite:2.3.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" gujun_span = "me.gujun.android:span:1.7" @@ -164,7 +164,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -ktlint = "org.jlleitschuh.gradle.ktlint:11.3.1" +ktlint = "org.jlleitschuh.gradle.ktlint:11.3.2" dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } paparazzi = "app.cash.paparazzi:1.2.0" diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt index bb74dff2a9..3be961598d 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -47,7 +47,9 @@ suspend fun (suspend () -> T).execute(state: MutableState>, errorMa } suspend fun (suspend () -> Result).executeResult(state: MutableState>) { - state.value = Async.Loading() + if (state.value !is Async.Success) { + state.value = Async.Loading() + } this().fold( onSuccess = { state.value = Async.Success(it) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt new file mode 100644 index 0000000000..302978066c --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt @@ -0,0 +1,22 @@ +/* + * 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.libraries.core.coroutine + +import kotlinx.coroutines.flow.flow + +/** Create a Flow emitting a single error event. It should be useful for tests. */ +fun errorFlow(throwable: Throwable) = flow { throw throwable } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt index 06aa4435a0..e5dcc0c5ff 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -41,7 +41,11 @@ import io.element.android.libraries.designsystem.theme.components.Text import timber.log.Timber @Composable -fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) { +fun Avatar( + avatarData: AvatarData, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { val commonModifier = modifier .size(avatarData.size.dp) .clip(CircleShape) @@ -54,6 +58,7 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) { ImageAvatar( avatarData = avatarData, modifier = commonModifier, + contentDescription = contentDescription, ) } } @@ -62,13 +67,14 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) { private fun ImageAvatar( avatarData: AvatarData, modifier: Modifier = Modifier, + contentDescription: String? = null, ) { AsyncImage( model = avatarData, onError = { Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable) }, - contentDescription = null, + contentDescription = contentDescription, contentScale = ContentScale.Crop, placeholder = debugPlaceholderAvatar(), modifier = modifier @@ -89,11 +95,11 @@ private fun InitialsAvatar( end = Offset(100f, 0f) ) Box( - modifier.background(brush = initialsGradient) + modifier.background(brush = initialsGradient), ) { Text( modifier = Modifier.align(Alignment.Center), - text = avatarData.getInitial(), + text = avatarData.initial, fontSize = (avatarData.size.dp / 2).value.sp, color = Color.White, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 3bf4f7d0b4..2d1e0558f4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -30,8 +30,37 @@ data class AvatarData( @IgnoredOnParcel val size: AvatarSize = AvatarSize.MEDIUM ) : Parcelable { - fun getInitial(): String { - val firstChar = name?.firstOrNull() ?: id.getOrNull(1) ?: '?' - return firstChar.uppercase() + + @IgnoredOnParcel + val initial by lazy { + (name?.takeIf { it.isNotBlank() } ?: id) + .let { dn -> + var startIndex = 0 + val initial = dn[startIndex] + + if (initial in listOf('@', '#', '+') && dn.length > 1) { + startIndex++ + } + + var length = 1 + var first = dn[startIndex] + + // LEFT-TO-RIGHT MARK + if (dn.length >= 2 && 0x200e == first.code) { + startIndex++ + first = dn[startIndex] + } + + // check if it’s the start of a surrogate pair + if (first.code in 0xD800..0xDBFF && dn.length > startIndex + 1) { + val second = dn[startIndex + 1] + if (second.code in 0xDC00..0xDFFF) { + length++ + } + } + + dn.substring(startIndex, startIndex + length) + } + .uppercase() } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt new file mode 100644 index 0000000000..a18d0ef3ed --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt @@ -0,0 +1,45 @@ +/* + * 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.libraries.designsystem.modifiers + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.debugInspectorInfo + +/** + * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. + */ +@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas +fun Modifier.applyIf( + condition: Boolean, + ifTrue: @Composable Modifier.() -> Modifier, + ifFalse: @Composable (Modifier.() -> Modifier)? = null +): Modifier = + composed( + inspectorInfo = debugInspectorInfo { + name = "applyIf" + value = condition + } + ) { + when { + condition -> then(ifTrue(Modifier)) + ifFalse != null -> then(ifFalse(Modifier)) + else -> this + } + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt new file mode 100644 index 0000000000..9675c54a20 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt @@ -0,0 +1,106 @@ +/* + * 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.libraries.designsystem.modifiers + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.debugInspectorInfo +import kotlin.math.sqrt + +// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8 + +/** + * A modifier that clips the composable content using an animated circle. The circle will + * expand/shrink with an animation whenever [visible] changes. + * + * For more fine-grained control over the transition, see this method's overload, which allows passing + * a [State] object to control the progress of the reveal animation. + * + * By default, the circle is centered in the content, but custom positions may be specified using + * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/ +fun Modifier.circularReveal( + visible: Boolean, + showScrim: Boolean = false, + revealFrom: Offset = Offset(0.5f, 0.5f), +): Modifier = composed( + factory = { + val factor = updateTransition(visible, label = "Visibility") + .animateFloat(label = "revealFactor") { if (it) 1f else 0f } + + circularReveal(factor, showScrim, revealFrom) + }, + inspectorInfo = debugInspectorInfo { + name = "circularReveal" + properties["visible"] = visible + properties["revealFrom"] = revealFrom + } +) + +/** + * A modifier that clips the composable content using a circular shape. The radius of the circle + * will be determined by the [transitionProgress]. + * + * The values of the progress should be between 0 and 1. + * + * By default, the circle is centered in the content, but custom positions may be specified using + * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom). + * */ +fun Modifier.circularReveal( + transitionProgress: State, + showScrim: Boolean = false, + revealFrom: Offset = Offset(0.5f, 0.5f) +): Modifier { + return drawWithCache { + val path = Path() + val center = revealFrom.mapTo(size) + val radius = calculateRadius(revealFrom, size) + val scrimColor = if (showScrim) + Color.Gray + else + Color.Transparent + + path.addOval(Rect(center, radius * transitionProgress.value)) + + onDrawWithContent { + if (showScrim) { + drawRect(scrimColor, alpha = transitionProgress.value * 0.75f) + } + clipPath(path) { this@onDrawWithContent.drawContent() } + } + } +} + +private fun Offset.mapTo(size: Size): Offset { + return Offset(x * size.width, y * size.height) +} + +private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) { + val x = (if (x > 0.5f) x else 1 - x) * size.width + val y = (if (y > 0.5f) y else 1 - y) * size.height + + sqrt(x * x + y * y) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt index 7c498a610c..d897f6ad4e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt @@ -21,12 +21,12 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetDefaults import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.contentColorFor import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -44,7 +44,7 @@ fun ModalBottomSheetLayout( sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), sheetShape: Shape = MaterialTheme.shapes.large, sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = MaterialTheme.colors.surface, + sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, sheetContentColor: Color = contentColorFor(sheetBackgroundColor), scrimColor: Color = ModalBottomSheetDefaults.scrimColor, content: @Composable () -> Unit = {} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt new file mode 100644 index 0000000000..33baf19dce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt @@ -0,0 +1,39 @@ +/* + * 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.libraries.designsystem.utils + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +fun WindowInsets.copy( + top: Int? = null, + right: Int? = null, + bottom: Int? = null, + left: Int? = null +): WindowInsets { + val density = LocalDensity.current + val direction = LocalLayoutDirection.current + return WindowInsets( + top = top ?: this.getTop(density), + right = right ?: this.getRight(density, direction), + bottom = bottom ?: this.getBottom(density), + left = left ?: this.getLeft(density, direction) + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 0427c147de..f0bd7d62a4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -36,6 +36,8 @@ interface MatrixClient : Closeable { val mediaLoader: MatrixMediaLoader fun getRoom(roomId: RoomId): MatrixRoom? fun findDM(userId: UserId): MatrixRoom? + suspend fun ignoreUser(userId: UserId): Result + suspend fun unignoreUser(userId: UserId): Result suspend fun createRoom(createRoomParams: CreateRoomParameters): Result suspend fun createDM(userId: UserId): Result fun startSync() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index 27fc15c2c6..991f8dd117 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -16,12 +16,19 @@ package io.element.android.libraries.matrix.api.notification -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +//TODO add content data class NotificationData( - val item: MatrixTimelineItem, - val title: String, - val subtitle: String?, + val senderId: UserId, + val eventId: EventId, + val roomId: RoomId, + val senderAvatarUrl: String? = null, + val senderDisplayName: String? = null, + val roomAvatarUrl: String? = null, + val isDirect: Boolean, + val isEncrypted: Boolean, val isNoisy: Boolean, - val avatarUrl: String?, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 70980cc753..35451874aa 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -18,12 +18,15 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import java.io.Closeable interface MatrixRoom : Closeable { + val sessionId: SessionId val roomId: RoomId val name: String? val bestName: String @@ -36,20 +39,22 @@ interface MatrixRoom : Closeable { val isDirect: Boolean val isPublic: Boolean - suspend fun members(): List + /** + * The current loaded members as a StateFlow. + * Initial value is [MatrixRoomMembersState.Unknown]. + * To update them you should call [updateMembers]. + */ + val membersStateFlow: StateFlow - suspend fun memberCount(): Int - - fun getMember(userId: UserId): RoomMember? - - fun getDmMember(): RoomMember? + /** + * Try to load the room members and update the membersFlow. + */ + suspend fun updateMembers(): Result fun syncUpdateFlow(): Flow fun timeline(): MatrixTimeline - suspend fun fetchMembers(): Result - suspend fun userDisplayName(userId: UserId): Result suspend fun userAvatarUrl(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt new file mode 100644 index 0000000000..4e41fd43ba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.matrix.api.room + +sealed interface MatrixRoomMembersState { + object Unknown : MatrixRoomMembersState + data class Pending(val prevRoomMembers: List? = null) : MatrixRoomMembersState + data class Error(val failure: Throwable, val prevRoomMembers: List? = null) : MatrixRoomMembersState + data class Ready(val roomMembers: List) : MatrixRoomMembersState +} + +fun MatrixRoomMembersState.roomMembers(): List? { + return when (this) { + is MatrixRoomMembersState.Ready -> roomMembers + is MatrixRoomMembersState.Pending -> prevRoomMembers + is MatrixRoomMembersState.Error -> prevRoomMembers + else -> null + } +} + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 25bb3e8a7e..3c9bd030b0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -16,11 +16,8 @@ package io.element.android.libraries.matrix.api.room -import android.os.Parcelable import io.element.android.libraries.matrix.api.core.UserId -import kotlinx.parcelize.Parcelize -@Parcelize data class RoomMember( val userId: UserId, val displayName: String?, @@ -30,7 +27,7 @@ data class RoomMember( val powerLevel: Long, val normalizedPowerLevel: Long, val isIgnored: Boolean, -) : Parcelable +) enum class RoomMembershipState { BAN, INVITE, JOIN, KNOCK, LEAVE diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index e6f6068c0d..bef0bc64c7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -200,7 +200,7 @@ class RustMatrixClient constructor( val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null val fullRoom = slidingSyncRoom.fullRoom() ?: return null return RustMatrixRoom( - currentUserId = sessionId, + sessionId = sessionId, slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow, slidingSyncRoom = slidingSyncRoom, innerRoom = fullRoom, @@ -214,6 +214,18 @@ class RustMatrixClient constructor( return roomId?.let { getRoom(it) } } + override suspend fun ignoreUser(userId: UserId): Result = withContext(dispatchers.io) { + runCatching { + client.ignoreUser(userId.value) + } + } + + override suspend fun unignoreUser(userId: UserId): Result = withContext(dispatchers.io) { + runCatching { + client.unignoreUser(userId.value) + } + } + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result = withContext(dispatchers.io) { runCatching { val rustParams = RustCreateRoomParameters( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index d4b8f4f72e..6d7d48db24 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -63,7 +63,7 @@ class RustMediaLoader( runCatching { mediaSourceFromUrl(url).use { mediaSource -> innerClient.getMediaFile( - source = mediaSource, + mediaSource = mediaSource, mimeType = mimeType ?: "application/octet-stream" ).use { Path(it.path()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index ae47beb700..079b1e0a5a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -16,35 +16,28 @@ package io.element.android.libraries.matrix.impl.notification +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.NotificationData -import io.element.android.libraries.matrix.impl.timeline.MatrixTimelineItemMapper -import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper -import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper -import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper -import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper import org.matrix.rustcomponents.sdk.NotificationItem import org.matrix.rustcomponents.sdk.use import javax.inject.Inject class NotificationMapper @Inject constructor() { - // TODO Inject and remove duplicate? - private val timelineItemFactory = MatrixTimelineItemMapper( - virtualTimelineItemMapper = VirtualTimelineItemMapper(), - eventTimelineItemMapper = EventTimelineItemMapper( - contentMapper = TimelineEventContentMapper( - eventMessageMapper = EventMessageMapper() - ) - ) - ) fun map(notificationItem: NotificationItem): NotificationData { return notificationItem.use { NotificationData( - item = timelineItemFactory.map(it.item), - title = it.title, - subtitle = it.subtitle, - isNoisy = it.isNoisy, - avatarUrl = it.avatarUrl, + senderId = UserId(it.event.senderId()), + eventId = EventId(it.event.eventId()), + roomId = RoomId(it.roomId), + senderAvatarUrl = it.senderAvatarUrl, + senderDisplayName = it.senderDisplayName, + roomAvatarUrl = it.roomAvatarUrl, + isDirect = it.isDirect, + isEncrypted = it.isEncrypted, + isNoisy = it.isNoisy ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 8afa3cb4ed..9caba227ce 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -17,21 +17,23 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId 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.RoomMember +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.SlidingSyncRoom @@ -40,7 +42,7 @@ import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown class RustMatrixRoom( - private val currentUserId: UserId, + override val sessionId: SessionId, private val slidingSyncUpdateFlow: Flow, private val slidingSyncRoom: SlidingSyncRoom, private val innerRoom: Room, @@ -48,38 +50,19 @@ class RustMatrixRoom( private val coroutineDispatchers: CoroutineDispatchers, ) : MatrixRoom { - private var loadMembersJob: Job? = null - private var cachedMembers: List = emptyList() + override val membersStateFlow: StateFlow + get() = _membersStateFlow - override suspend fun members(): List { - return cachedMembers.ifEmpty { - if (loadMembersJob == null) { - loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) { - cachedMembers = tryOrNull { - innerRoom.members().map(RoomMemberMapper::map) - } ?: emptyList() - } - } - loadMembersJob?.join() - loadMembersJob = null - cachedMembers - } - } + private var _membersStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) - override suspend fun memberCount(): Int { - return members().size - } - - override fun getMember(userId: UserId): RoomMember? { - return cachedMembers.find { it.userId == userId } - } - - override fun getDmMember(): RoomMember? { - return if (cachedMembers.size == 2 && isDirect && isEncrypted) { - cachedMembers.find { it.userId != currentUserId } - } else { - null - } + private val timeline by lazy { + RustMatrixTimeline( + matrixRoom = this, + innerRoom = innerRoom, + slidingSyncRoom = slidingSyncRoom, + coroutineScope = coroutineScope, + coroutineDispatchers = coroutineDispatchers + ) } override fun syncUpdateFlow(): Flow { @@ -94,13 +77,7 @@ class RustMatrixRoom( } override fun timeline(): MatrixTimeline { - return RustMatrixTimeline( - matrixRoom = this, - innerRoom = innerRoom, - slidingSyncRoom = slidingSyncRoom, - coroutineScope = coroutineScope, - coroutineDispatchers = coroutineDispatchers - ) + return timeline } override fun close() { @@ -150,9 +127,16 @@ class RustMatrixRoom( override val isDirect: Boolean get() = innerRoom.isDirect() - override suspend fun fetchMembers(): Result = withContext(coroutineDispatchers.io) { + override suspend fun updateMembers(): Result = withContext(coroutineDispatchers.io) { + val currentState = _membersStateFlow.value + val currentMembers = currentState.roomMembers() + _membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers) runCatching { - innerRoom.fetchMembers() + innerRoom.members().map(RoomMemberMapper::map) + }.map { + _membersStateFlow.value = MatrixRoomMembersState.Ready(it) + }.onFailure { + _membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = it) } } @@ -208,13 +192,13 @@ class RustMatrixRoom( } override suspend fun acceptInvitation(): Result = withContext(coroutineDispatchers.io) { - kotlin.runCatching { + runCatching { innerRoom.acceptInvitation() } } override suspend fun rejectInvitation(): Result = withContext(coroutineDispatchers.io) { - kotlin.runCatching { + runCatching { innerRoom.rejectInvitation() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index e942f31b76..6f64db76ef 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -42,6 +42,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineListener import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean class RustMatrixTimeline( private val matrixRoom: MatrixRoom, @@ -51,6 +52,8 @@ class RustMatrixTimeline( private val coroutineDispatchers: CoroutineDispatchers, ) : MatrixTimeline { + private val isInit = AtomicBoolean(false) + private val timelineItems: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -95,6 +98,7 @@ class RustMatrixTimeline( withContext(coroutineDispatchers.diffUpdateDispatcher) { this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems } + isInit.set(true) } .onFailure { Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})") @@ -105,6 +109,7 @@ class RustMatrixTimeline( override fun dispose() { Timber.v("Dispose timeline for room ${matrixRoom.roomId}") listenerTokens.dispose() + isInit.set(false) } /** @@ -125,6 +130,9 @@ class RustMatrixTimeline( override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result = withContext(coroutineDispatchers.io) { runCatching { Timber.v("Start back paginating for room ${matrixRoom.roomId} ") + if (!isInit.get()) { + throw IllegalStateException("Timeline is not init yet") + } val paginationOptions = PaginationOptions.UntilNumItems( eventLimit = requestSize.toUShort(), items = untilNumberOfItems.toUShort() @@ -139,10 +147,26 @@ class RustMatrixTimeline( private suspend fun addListener(timelineListener: TimelineListener): Result> = withContext(coroutineDispatchers.io) { runCatching { - val settings = RoomSubscription(requiredState = listOf(RequiredState(key = "m.room.canonical_alias", value = "")), timelineLimit = null) + val settings = RoomSubscription( + requiredState = listOf( + RequiredState(key = "m.room.canonical_alias", value = ""), + RequiredState(key = "m.room.topic", value = ""), + RequiredState(key = "m.room.join_rules", value = ""), + ), + timelineLimit = null + ) val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings) + launch { + fetchMembers() + } listenerTokens += result.taskHandle result.items } } + + private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.fetchMembers() + } + } } diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 9f1d35112f..6d9ca1eb8e 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -23,6 +23,7 @@ android { } dependencies { + api(projects.libraries.core) api(projects.libraries.matrix.api) api(libs.coroutines.core) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 0167f2f846..95236d185f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -48,6 +48,8 @@ class FakeMatrixClient( private val notificationService: FakeNotificationService = FakeNotificationService(), ) : MatrixClient { + private var ignoreUserResult: Result = Result.success(Unit) + private var unignoreUserResult: Result = Result.success(Unit) private var createRoomResult: Result = Result.success(A_ROOM_ID) private var createDmResult: Result = Result.success(A_ROOM_ID) private var createDmFailure: Throwable? = null @@ -63,6 +65,14 @@ class FakeMatrixClient( return findDmResult } + override suspend fun ignoreUser(userId: UserId): Result { + return ignoreUserResult + } + + override suspend fun unignoreUser(userId: UserId): Result { + return unignoreUserResult + } + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result { delay(100) return createRoomResult @@ -119,6 +129,14 @@ class FakeMatrixClient( createDmResult = result } + fun givenIgnoreUserResult(result: Result) { + ignoreUserResult = result + } + + fun givenUnignoreUserResult(result: Result) { + unignoreUserResult = result + } + fun givenCreateDmError(failure: Throwable?) { createDmFailure = failure } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 05890f9e4a..28d6525739 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -18,17 +18,21 @@ package io.element.android.libraries.matrix.test.room import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId 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.RoomMember +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow class FakeMatrixRoom( + override val sessionId: SessionId = A_SESSION_ID, override val roomId: RoomId = A_ROOM_ID, override val name: String? = null, override val bestName: String = "", @@ -40,19 +44,16 @@ class FakeMatrixRoom( override val alternativeAliases: List = emptyList(), override val isPublic: Boolean = true, override val isDirect: Boolean = false, - private val members: List = emptyList(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { + private var ignoreResult: Result = Result.success(Unit) + private var unignoreResult: Result = Result.success(Unit) private var userDisplayNameResult = Result.success(null) private var userAvatarUrlResult = Result.success(null) + private var updateMembersResult: Result = Result.success(Unit) private var acceptInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit) - private var dmMember: RoomMember? = null - private var fetchMemberResult: Result = Result.success(Unit) - - var areMembersFetched: Boolean = false - private set var isInviteAccepted: Boolean = false private set @@ -62,6 +63,12 @@ class FakeMatrixRoom( private var leaveRoomError: Throwable? = null + override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) + + override suspend fun updateMembers(): Result { + return updateMembersResult + } + override fun syncUpdateFlow(): Flow { return emptyFlow() } @@ -70,18 +77,6 @@ class FakeMatrixRoom( return matrixTimeline } - override suspend fun fetchMembers(): Result { - return fetchMemberResult.also { result -> - if (result.isSuccess) { - areMembersFetched = true - } - } - } - - override fun getDmMember(): RoomMember? { - return dmMember - } - override suspend fun userDisplayName(userId: UserId): Result { return userDisplayNameResult } @@ -90,22 +85,6 @@ class FakeMatrixRoom( return userAvatarUrlResult } - override suspend fun members(): List { - return members - } - - override suspend fun memberCount(): Int { - if (fetchMemberResult.isSuccess) { - return members.count() - } else { - throw fetchMemberResult.exceptionOrNull()!! - } - } - - override fun getMember(userId: UserId): RoomMember? { - return members.firstOrNull { it.userId == userId } - } - override suspend fun sendMessage(message: String): Result { delay(100) return Result.success(Unit) @@ -155,12 +134,12 @@ class FakeMatrixRoom( this.leaveRoomError = throwable } - fun givenFetchMemberResult(result: Result) { - fetchMemberResult = result + fun givenRoomMembersState(state: MatrixRoomMembersState) { + membersStateFlow.value = state } - fun givenDmMember(roomMember: RoomMember) { - this.dmMember = roomMember + fun givenUpdateMembersResult(result: Result) { + updateMembersResult = result } fun givenUserDisplayNameResult(displayName: Result) { @@ -179,4 +158,11 @@ class FakeMatrixRoom( rejectInviteResult = result } + fun givenIgnoreResult(result: Result) { + ignoreResult = result + } + + fun givenUnIgnoreResult(result: Result) { + unignoreResult = result + } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt new file mode 100644 index 0000000000..061ce365eb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -0,0 +1,70 @@ +/* + * 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.libraries.matrix.ui.room + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.roomMembers + +@Composable +fun MatrixRoom.getRoomMember(userId: UserId): State { + val roomMembersState by membersStateFlow.collectAsState() + return getRoomMember(roomMembersState = roomMembersState, userId = userId) +} + +@Composable +fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembers) { + derivedStateOf { + roomMembers?.find { + it.userId == userId + } + } + } +} + +@Composable +fun MatrixRoom.getDirectRoomMember(): State { + val roomMembersState by membersStateFlow.collectAsState() + return getDirectRoomMember(roomMembersState = roomMembersState) +} + +@Composable +fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembers) { + derivedStateOf { + if (roomMembers == null) { + null + } else if (roomMembers.size == 2 && isDirect && isEncrypted) { + roomMembers.find { it.userId != this.sessionId } + } else { + null + } + } + } +} + diff --git a/libraries/mediapickers/build.gradle.kts b/libraries/mediapickers/build.gradle.kts new file mode 100644 index 0000000000..444244d2f0 --- /dev/null +++ b/libraries/mediapickers/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.mediapickers" + + dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(libs.inject) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) + } +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt new file mode 100644 index 0000000000..16f21a9683 --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt @@ -0,0 +1,51 @@ +/* + * 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.libraries.mediapickers + +import androidx.activity.compose.ManagedActivityResultLauncher + +/** + * Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers. + */ +interface PickerLauncher { + /** Starts the activity result launcher with its default input. */ + fun launch() + + /** Starts the activity result launcher with a [customInput]. */ + fun launch(customInput: Input) +} + +class ComposePickerLauncher( + private val managedLauncher: ManagedActivityResultLauncher, + private val defaultRequest: Input, +) : PickerLauncher { + override fun launch() { + managedLauncher.launch(defaultRequest) + } + + override fun launch(customInput: Input) { + managedLauncher.launch(customInput) + } +} + +/** Needed for screenshot tests. */ +class NoOpPickerLauncher( + private val onResult: () -> Unit, +) : PickerLauncher { + override fun launch() = onResult() + override fun launch(customInput: Input) = onResult() +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt new file mode 100644 index 0000000000..720378aaca --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt @@ -0,0 +1,150 @@ +/* + * 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.libraries.mediapickers + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.core.content.FileProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import java.io.File +import java.util.UUID +import javax.inject.Inject + +class PickerProvider constructor(private val isInTest: Boolean) { + + @Inject + constructor(): this(false) + + /** + * Remembers and returns a [PickerLauncher] for a certain media/file [type]. + */ + @Composable + internal fun rememberPickerLauncher( + type: PickerType, + onResult: (Output) -> Unit, + ): PickerLauncher { + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { } + } else { + val contract = type.getContract() + val managedLauncher = rememberLauncherForActivityResult(contract = contract, onResult = onResult) + remember(type) { ComposePickerLauncher(managedLauncher, type.getDefaultRequest()) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video. + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default). + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for taking a photo with a camera app. + * @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected. + * @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult]. + * It's `true` by default. + */ + @Composable + fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + // Then remove the file and clear the picker + if (deleteAfter) { + tmpFile.delete() + } + } + } + } + + /** + * Remembers and returns a [PickerLauncher] for recording a video with a camera app. + * @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected. + * @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult]. + * It's `true` by default. + */ + @Composable + fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + // Then remove the file and clear the picker + if (deleteAfter) { + tmpFile.delete() + } + } + } + } + + private fun getTemporaryFile( + context: Context, + baseFolder: File = context.cacheDir, + filename: String = UUID.randomUUID().toString(), + ): File { + return File(baseFolder, filename) + } + + private fun getTemporaryUri( + context: Context, + file: File, + ): Uri { + val authority = "${context.packageName}.fileprovider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt new file mode 100644 index 0000000000..354d9a4918 --- /dev/null +++ b/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt @@ -0,0 +1,58 @@ +/* + * 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.libraries.mediapickers + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import io.element.android.libraries.core.mimetype.MimeTypes + +sealed interface PickerType { + fun getContract(): ActivityResultContract + fun getDefaultRequest(): Input + + object ImageAndVideo : PickerType { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + } + + object Camera { + data class Photo(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.TakePicture() + override fun getDefaultRequest(): Uri { + return destUri + } + } + + data class Video(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.CaptureVideo() + override fun getDefaultRequest(): Uri { + return destUri + } + } + } + + data class File(val mimeType: String = MimeTypes.Any) : PickerType { + override fun getContract() = ActivityResultContracts.GetContent() + override fun getDefaultRequest(): String { + return mimeType + } + } +} diff --git a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt b/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt new file mode 100644 index 0000000000..693ef40cfe --- /dev/null +++ b/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt @@ -0,0 +1,65 @@ +/* + * 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.libraries.mediapickers + +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PickerTypeTests { + + @Test + fun `ImageAndVideo - assert types`() { + val pickerType = PickerType.ImageAndVideo + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.PickVisualMedia::class.java) + assertThat(pickerType.getDefaultRequest().mediaType).isEqualTo(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + + @Test + fun `File - assert types`() { + val pickerType = PickerType.File() + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(MimeTypes.Any) + + val mimeType = MimeTypes.Images + val customPickerType = PickerType.File(mimeType) + assertThat(customPickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(customPickerType.getDefaultRequest()).isEqualTo(mimeType) + } + + @Test + fun `CameraPhoto - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Photo(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.TakePicture::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + + @Test + fun `CameraVideo - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Video(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.CaptureVideo::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index 3c82496626..82e3e43d50 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -24,11 +24,6 @@ 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.notification.NotificationData -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem -import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent -import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails -import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent @@ -74,28 +69,28 @@ class NotifiableEventResolver @Inject constructor( } ).orDefault(roomId, eventId) - return notificationData.asNotifiableEvent(sessionId, roomId, eventId) + return notificationData.asNotifiableEvent(sessionId) } } -private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent { +private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent { return NotifiableMessageEvent( sessionId = userId, roomId = roomId, eventId = eventId, editedEventId = null, canBeReplaced = true, - noisy = false, + noisy = isNoisy, timestamp = System.currentTimeMillis(), - senderName = null, - senderId = null, + senderName = senderDisplayName, + senderId = senderId.value, body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…", imageUriString = null, threadId = null, roomName = null, roomIsDirect = false, - roomAvatarPath = null, - senderAvatarPath = null, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, soundName = null, outGoingMessage = false, outGoingMessageFailed = false, @@ -109,33 +104,11 @@ private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId */ private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { return this ?: NotificationData( - item = MatrixTimelineItem.Event( - event = EventTimelineItem( - uniqueIdentifier = eventId.value, - eventId = eventId, - isEditable = false, - isLocal = false, - isOwn = false, - isRemote = false, - localSendState = null, - reactions = emptyList(), - sender = UserId(""), - senderProfile = ProfileTimelineDetails.Unavailable, - timestamp = System.currentTimeMillis(), - content = MessageContent( - body = eventId.value, - inReplyTo = null, - isEdited = false, - type = TextMessageType( - body = eventId.value, - formatted = null - ) - ) - ), - ), - title = roomId.value, - subtitle = eventId.value, + eventId = eventId, + senderId = UserId("@user:domain"), + roomId = roomId, isNoisy = false, - avatarUrl = null, + isEncrypted = false, + isDirect = false ) } diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..4d663c57aa --- /dev/null +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,30 @@ + + + "Laute Benachrichtigungen" + "Beitreten" + "Ablehnen" + "Neue Nachrichten" + "Als gelesen markieren" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s in %2$s und %3$s" + + "%1$s: %2$d Nachricht" + "%1$s: %2$d Nachrichten" + + + "%d Einladung" + "%d Einladungen" + + + "%d neue Nachricht" + "%d neue Nachrichten" + + + "%d Raum" + "%d Räume" + + "Google-Dienste" + "Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig." + "Schnellantwort" + \ No newline at end of file diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..31df508dc3 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,4 @@ + + + "Respuesta rápida" + \ No newline at end of file diff --git a/libraries/push/impl/src/main/res/values-it/translations.xml b/libraries/push/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..32957fe2ce --- /dev/null +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ + + + "Risposta rapida" + \ No newline at end of file diff --git a/libraries/push/impl/src/main/res/values-ro/translations.xml b/libraries/push/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..47280228a4 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,54 @@ + + + "Apel" + "Ascultare evenimente" + "Notificări zgomotoase" + "Notificări silențioase" + "** Trimiterea eșuată - vă rugăm să deschideți camera" + "Alăturați-vă" + "Respingeți" + "Mesaje noi" + "Marcați ca citit" + "Eu" + "Vizualizați o notificare! Faceți clic pe mine!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s și %2$s" + "%1$s în %2$s" + "%1$s în %2$s și %3$s" + + "%1$s: %2$d mesaj" + + "%1$s: %2$d mesaje" + + + "%d notificare" + + "%d notificări" + + + "%d invitație" + + "%d invitații" + + + "%d mesaj nou" + + "%d mesaje noi" + + + "%d mesaj notificat necitit" + + "%d mesaje notificate necitite" + + + "%d cameră" + + "%d camere" + + "Alegeți modul de primire a notificărilor" + "Sincronizare în fundal" + "Servicii Google" + "Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect." + "Raspuns rapid" + diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 3a11adb5d3..d38bf7d8dd 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -9,7 +9,6 @@ "Reject" "New Messages" "Mark as read" - "Quick reply" "Me" "You are viewing the notification! Click me!" "%1$s: %2$s" @@ -45,4 +44,5 @@ "Background synchronization" "Google Services" "No valid Google Play Services found. Notifications may not work properly." + "Quick reply" \ No newline at end of file diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index a254a636ef..df12c755e3 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -38,6 +38,11 @@ object TestTags { */ val changeServerServer = TestTag("change_server-server") val changeServerContinue = TestTag("change_server-continue") + + /** + * Room list / Home screen. + */ + val homeScreenSettings = TestTag("home_screen-settings") } diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 3481a56388..bab573de58 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -50,6 +50,7 @@ fun TextComposer( onFullscreenToggle: () -> Unit = {}, onCloseSpecialMode: () -> Unit = {}, onComposerTextChange: (CharSequence) -> Unit = {}, + onAddAttachment:() -> Unit = {}, ) { if (LocalInspectionMode.current) { FakeComposer(modifier) @@ -78,6 +79,7 @@ fun TextComposer( } override fun onAddAttachment() { + onAddAttachment() } override fun onExpandOrCompactChange() { diff --git a/libraries/textcomposer/src/main/res/values-de/translations.xml b/libraries/textcomposer/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..f016d4bdba --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "Nachricht…" + "Link setzen" + \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 22c60db481..4093aae962 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -1,5 +1,106 @@ + "Passwort ausblenden" + "Dateien senden" + "Passwort anzeigen" + "Benutzermenü" + "Zurück" + "Abbrechen" + "Foto auswählen" + "Schließen" + "Verifizierung abschließen" "Bestätigen" + "Kopieren" + "Link kopieren" + "Erstellen" + "Ablehnen" + "Deaktivieren" + "Fertig" + "Bearbeiten" + "Aktivieren" + "Einladen" + "Freunde zu %1$s einladen" + "Einladungen" + "Mehr erfahren" + "Verlassen" + "Raum verlassen" + "Weiter" + "Nein" + "OK" + "Schnellantwort" + "Zitieren" + "Entfernen" + "Fehler melden" + "Inhalt melden" + "Erneut versuchen" + "Entschlüsselung erneut versuchen" + "Speichern" + "Suchen" + "Senden" + "Nachricht senden" + "Teilen" + "Link teilen" + "Überspringen" + "Foto aufnehmen" + "Ja" + "Über" + "Analytik" + "Audio" + "Blasen" + "Entschlüsselungsfehler" + "Entwickleroptionen" + "(bearbeitet)" + "Verschlüsselung aktiviert" + "Fehler" + "Datei" + "GIF" + "Bild" + "Link in Zwischenablage kopiert" + "Nachricht" + "Modern" + "Offline" + "Passwort" + "Reaktionen" + "Sicherheit" + "Einstellungen" + "Sticker" + "Erfolg" + "Vorschläge" + "Thema" + "Entschlüsselung nicht möglich" + "Nicht unterstütztes Ereignis" + "Benutzername" + "Verifizierung abgebrochen" + "Verifizierung abgeschlossen" + "Video" + "Warten…" + "Warnung" + "Aktivitäten" + "Flaggen" + "Essen & Trinken" + "Tiere & Natur" + "Objekte" + "Smileys & Personen" + "Reisen & Orte" + "Symbole" + "Fehler beim Laden der Nachrichten" + "Entschuldigung, ein Fehler ist aufgetreten." + "%1$s Android" + + "%1$d Mitglied" + "%1$d Mitglieder" + + "Grund für die Meldung dieses Inhalts" + "Dies ist der Anfang von %1$s." + "Neu" + "Blockieren" + "Nutzer blockieren" + "Blockierung aufheben" + "Nutzer entblockieren" + "Erkennungsschwelle" + "Version: %1$s (%2$s)" "de" + "Fehler" + "Erfolg" + "Nutzer blockieren" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index 564ede34a8..b430048f79 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -94,8 +94,6 @@ "Vídeo" "Esperando…" "Confirmar" - "Error" - "Terminado" "Atención" "Actividades" "Banderas" @@ -129,7 +127,6 @@ "Este es el principio de %1$s." "Este es el principio de esta conversación." "Nuevos" - "Bloquear usuario" "Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario" "Bloquear" "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento." @@ -142,4 +139,7 @@ "General" "Versión: %1$s (%2$s)" "es" + "Error" + "Terminado" + "Bloquear usuario" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index a8ec05115a..8fbe54dae2 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -94,8 +94,6 @@ "Video" "In attesa…" "Conferma" - "Errore" - "Operazione riuscita" "Attenzione" "Attività" "Bandiere" @@ -129,7 +127,6 @@ "Questo è l\'inizio di %1$s." "Questo è l\'inizio della conversazione." "Nuovo" - "Blocca utente" "Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente" "Blocca" "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." @@ -142,4 +139,7 @@ "Generali" "Versione: %1$s (%2$s)" "it" + "Errore" + "Operazione riuscita" + "Blocca utente" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 091dd7f36d..2a9dc93490 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -4,8 +4,10 @@ "Trimiteți fișiere" "Afișați parola" "Meniu utilizator" + "Acceptați" "Înapoi" "Anulați" + "Alegeți o fotografie" "Ștergeți" "Închideți" "Verificare completă" @@ -13,13 +15,16 @@ "Continuați" "Copiați" "Copiați linkul" + "Creați" "Creați o cameră" + "Refuzați" "Dezactivați" "Efectuat" "Editați" "Activați" "Invitați" "Invitați prieteni în %1$s" + "Invitații" "Aflați mai multe" "Părăsiți" "Părăsiți camera" @@ -38,15 +43,18 @@ "Salvați" "Căutați" "Trimiteți" + "Trimiteți mesajul" "Partajați" "Partajați linkul" "Omiteți" "Începeți" "Începeți discuția" "Începeți verificarea" + "Faceți o fotografie" "Vedeți sursă" "Da" "Despre" + "Analitice" "Audio" "Baloane" "Se creează camera…" @@ -94,8 +102,6 @@ "Video" "Se aşteaptă…" "Confirmare" - "Eroare" - "Succes" "Avertisment" "Activități" "Steaguri" @@ -131,7 +137,17 @@ "Acesta este începutul conversației %1$s." "Acesta este începutul acestei conversații." "Nou" - "Blocați utilizatorul" + "Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime." + "Nu"" înregistrăm sau profilăm datele contului" + "Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime." + "Puteți citi toate condițiile noastre %1$s." + "aici" + "Puteți dezactiva această opțiune oricând din setări" + "Nu"" împărtășim informații cu terți" + "Ajutați la îmbunătățirea %1$s" + "Puteți citi toate condițiile noastre %1$s." + "aici" + "Partajați datele analitice" "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator" "Blocați" "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." @@ -144,4 +160,7 @@ "General" "Versiunea: %1$s (%2$s)" "ro" + "Eroare" + "Succes" + "Blocați utilizatorul" \ No newline at end of file diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index bfd2be2000..5df318b7ae 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -102,8 +102,6 @@ "Video" "Waiting…" "Confirmation" - "Error" - "Success" "Warning" "Activities" "Flags" @@ -122,60 +120,15 @@ "Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite." "Are you sure that you want to leave the room?" "%1$s Android" - "Call" - "Listening for events" - "Noisy notifications" - "Silent notifications" - "** Failed to send - please open room" - "Join" - "Reject" - "New Messages" - "Mark as read" - "Quick reply" - "Me" - "You are viewing the notification! Click me!" - "%1$s: %2$s" - "%1$s: %2$s %3$s" - "%1$s and %2$s" - "%1$s in %2$s" - "%1$s in %2$s and %3$s" "%1$d member" "%1$d members" - - "%1$s: %2$d message" - "%1$s: %2$d messages" - - - "%d notification" - "%d notifications" - - - "%d invitation" - "%d invitations" - - - "%d new message" - "%d new messages" - - - "%d unread notified message" - "%d unread notified messages" - - - "%d room" - "%d rooms" - "%1$d room change" "%1$d room changes" "Rageshake to report bug" - "Choose how to receive notifications" - "Background synchronization" - "Google Services" - "No valid Google Play Services found. Notifications may not work properly." "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." "Reason for reporting this content" @@ -193,7 +146,6 @@ "You can read all our terms %1$s." "here" "Share analytics data" - "Block user" "Check if you want to hide all current and future messages from this user" "Block" "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime." @@ -207,4 +159,7 @@ "Version: %1$s (%2$s)" "en" "en" + "Error" + "Success" + "Block user" \ No newline at end of file diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 5d1ef9a20e..18ce6e55f8 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -90,7 +90,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:di")) implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:statemachine")) - + implementation(project(":libraries:mediapickers")) } fun DependencyHandlerScope.allServicesImpl() { diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index 44167f906f..fda44f46d1 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -34,4 +34,6 @@ dependencies { implementation(libs.coroutines.test) implementation(projects.libraries.matrix.test) implementation(projects.services.appnavstate.test) + implementation(projects.services.appnavstate.test) + implementation(projects.libraries.core) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt new file mode 100644 index 0000000000..1309a14cb1 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt @@ -0,0 +1,46 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.tests.testutils + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +fun testCoroutineDispatchers( + testScheduler: TestCoroutineScheduler? = null, +) = CoroutineDispatchers( + io = UnconfinedTestDispatcher(testScheduler), + computation = UnconfinedTestDispatcher(testScheduler), + main = UnconfinedTestDispatcher(testScheduler), + diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler), +) + +fun testCoroutineDispatchers( + io: TestDispatcher = UnconfinedTestDispatcher(), + computation: TestDispatcher = UnconfinedTestDispatcher(), + main: TestDispatcher = UnconfinedTestDispatcher(), + diffUpdateDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) = CoroutineDispatchers( + io = io, + computation = computation, + main = main, + diffUpdateDispatcher = diffUpdateDispatcher, +) diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 78ce1fa217..5555d6e6b3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 -size 4478 +oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b +size 4483 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 78ce1fa217..5555d6e6b3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 -size 4478 +oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b +size 4483 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 88a70ce698..c18cc0dd08 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49aafbdb693cccd3ab7ca94e61f7a1de437766a29967dd89c4aaf5134b55c004 -size 14860 +oid sha256:db69f27f60dd9d93bb4d313741b84aa4a3ed008d229590338514c7683c0e3a11 +size 14786 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png index 665c8811ac..eb47d1cd71 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b -size 4457 +oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e +size 4490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png index 665c8811ac..eb47d1cd71 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b -size 4457 +oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e +size 4490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png index f7378511e1..94f6bbbd99 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:857fedd347931600aa5613565887384579b565efb86d5eeca6e93c13f5ff442c -size 13994 +oid sha256:6c630475e03d86195a0ebcc57bd12934b799fee956c635b30df60913cd9a3f50 +size 16032 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..de77e469f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8c78efed997712873719636ff1f8479d38d317e443c5d4340346d4328de9c0d +size 28744 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..de77e469f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8c78efed997712873719636ff1f8479d38d317e443c5d4340346d4328de9c0d +size 28744 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f269338d7c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f62537b4aa79f501908d1fff9d269139e88db5f6dbaedcf63670a4f71b47bff +size 28303 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f269338d7c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f62537b4aa79f501908d1fff9d269139e88db5f6dbaedcf63670a4f71b47bff +size 28303 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png index e9e352cb20..4ec6a0f362 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:354452861d1e006a8bfa744251ffdaf15088e0bb181a53043f121e606233d648 -size 67340 +oid sha256:a8a9186d741d251dc8bdf6bcf71d395e8c057c310585aeb8911609eaaacce842 +size 64550 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png index 8dd1cd9116..4ec6a0f362 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1cb6bac9e72b956d0cffde807340e36a7a4d6873d4d7337995b53e82769c4f9 -size 68135 +oid sha256:a8a9186d741d251dc8bdf6bcf71d395e8c057c310585aeb8911609eaaacce842 +size 64550 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png index 815e64ba9f..562eb63427 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc56e29c26250c6fef312ec4c5fdfaa2f63159fd0565bd37522b46c7ff67906a -size 61924 +oid sha256:94a589b556a750485fd61af6446457c98c5f112d0d013cd78016b88a8829e6a8 +size 58643 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png index 687b034bee..562eb63427 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6faff25a1c187dee59d0dcf0affd5029251512efb6eff7fc2d41d34bace2061 -size 62356 +oid sha256:94a589b556a750485fd61af6446457c98c5f112d0d013cd78016b88a8829e6a8 +size 58643 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png index 167599e090..ec4ab9f3e7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8bca09418758a20493dae2e73a747449af8a448ad3a3cc4c5aae2e08a425f3fb -size 13464 +oid sha256:b5f2b24a19ca49b3e6e34ccd65d2bdba72d0384104931bda92e191959d58c5c3 +size 12697 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png index 7efc250776..ddbf6df0b5 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e50325c75193e47958862ea9cb515d7c84d2c47a00b01256fc244319780c107f -size 12425 +oid sha256:c4fa32eb24a0cc51b9b19c6f24a7d3d59aae65f1f30a43b1a6d70b3ed3e2154d +size 11716 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListSearchResultContentPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListSearchResultContentPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b113ba20f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListSearchResultContentPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0 +size 27758 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 4e36616412..e4b5f95b8d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf -size 37781 +oid sha256:3cdb131c68de1fce5a3319151e39148e9f3a71c7bc3984e89ec0a80abf0f7288 +size 37044 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 0d3bec9cf5..1dcb6593b1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d729c75d73d1837b365c8332b6e4203cb2492f6c5c4af06741a4bd2e818daebb -size 60667 +oid sha256:8b265978c4db7b266fd07d56364eccafba1cd765ed9bf6d5a03b1584e173ba6a +size 59936 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 4e36616412..e4b5f95b8d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf -size 37781 +oid sha256:3cdb131c68de1fce5a3319151e39148e9f3a71c7bc3984e89ec0a80abf0f7288 +size 37044 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index c454377b9b..52c50a59a1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56a253a7981823ca0fc7b653f9e83d29d1f259fea43ca4cee5760fa863306f2c -size 39847 +oid sha256:ccf989dac7fad3cc70443d96e1ebd519463a6559ed0795ae2a1ffbaf91bdfe7c +size 39092 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index 2063e4eeee..e7c9ed7df3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b1222e1ef2d0739caa410540bf74fea681ef2e05bb04b976e50f1fe5256d613 -size 39762 +oid sha256:c2a23141c6cc8aa6e7e5f0757bda4d1117bf7f752412ccc4649ea560113b3e3f +size 39030 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..069af4cecf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9095546c30bb5bc9800c852456fa9cd82d14e873a5e1488d29496af088e951da +size 4882 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9861b7572e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cfe321535e1ce223a0460435123dc59e74c436bdd8696cf4bdb2169f511832b +size 28541 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 4a51d9cddc..2aa0933d88 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b -size 37329 +oid sha256:a2a261b30866af95b856ee1e7d6ac2cbe2d638cf80277645f361b043d2f94e60 +size 36658 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 92729ff9ed..2fcea72749 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f61d4fc4d5af166e1761f004e7d80934eef24a582c96c590c769ed1fb13041a4 -size 59489 +oid sha256:0cdd1fad4b3db78fb8599785334366e3bfbaf5dead7990f396f8752862ab9e98 +size 58987 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 4a51d9cddc..2aa0933d88 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b -size 37329 +oid sha256:a2a261b30866af95b856ee1e7d6ac2cbe2d638cf80277645f361b043d2f94e60 +size 36658 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index 1287c0ec4f..2664376698 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bf9529823b4e04261b1528b2309c549f1d3aad80c542059f11c1b26a2979b04 -size 39359 +oid sha256:1ba3d8a5cfbd102dc6df8f511eb14663170a6d550ee5e65702bc1f3fce3efd14 +size 38657 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index 273e7af0cf..5793650c81 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebb15a58e5d3497b2819f5a6b6fde88962f01cc2ae02d4b8cda0e45e1dde677f -size 39314 +oid sha256:d427b479f9eb6227bb92aecd997bab97a735ef85dd279a575d777e754effd258 +size 38639 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6c14f8062c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d463ab6d045cc310973c5ff900cf4d9ae04e93cb1c7eac3f9b2aa0ea9b827cee +size 4815 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b113ba20f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0 +size 27758 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png index a4aa94b6e4..a64e1b5a7a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddbb6611ae83055106f7b67ec828542f8a896cafb49001ed0baef43633cc77c1 -size 8884 +oid sha256:428372bd789ff5e83ba4d837bbb0592edd8a01571728c7b284c57ddb200226ed +size 9407 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png index 317643c598..9aa01dc971 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c9a3c9f68a6627654856b03d2534ae1e4e8e600989bc3719407fbf8e17a7ab1 -size 8631 +oid sha256:718cf6e3323ceb9bbf8b4dd9752203d5137840bd3dfb538008d579511b177412 +size 9504