diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml index 1fa53d24ef..f16a282d37 100644 --- a/.github/workflows/maestro.yml +++ b/.github/workflows/maestro.yml @@ -42,7 +42,7 @@ jobs: name: elementx-debug path: | app/build/outputs/apk/debug/*.apk - - uses: mobile-dev-inc/action-maestro-cloud@v1.3.2 + - uses: mobile-dev-inc/action-maestro-cloud@v1.3.3 with: api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} app-file: app/build/outputs/apk/debug/app-universal-debug.apk diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e668c17275..86e823f335 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,7 +9,7 @@ on: # Enrich gradle.properties for CI/CD env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false - CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn jobs: check: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b6e5eaf7d..aea05b5a22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ on: # Enrich gradle.properties for CI/CD env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false - CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn jobs: tests: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da9a4ab3e9..d4e889adc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -206,15 +206,14 @@ knit { dependencies { allLibrariesImpl() allServicesImpl() - allFeaturesImpl(rootDir) + allFeaturesImpl(rootDir, logger) implementation(projects.libraries.deeplink) implementation(projects.tests.uitests) implementation(projects.anvilannotations) implementation(projects.appnav) anvil(projects.anvilcodegen) - // https://developer.android.com/studio/write/java8-support#library-desugaring-versions - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") + coreLibraryDesugaring(libs.android.desugar) implementation(libs.appyx.core) implementation(libs.androidx.splash) implementation(libs.androidx.core) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 5b1c4b1ced..2ed497567f 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -36,7 +36,7 @@ dependencies { implementation(libs.dagger) kapt(libs.dagger.compiler) - allFeaturesApi(rootDir) + allFeaturesApi(rootDir, logger) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index b8c2c7742a..62c58caadb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -135,7 +135,10 @@ class LoggedInFlowNode @AssistedInject constructor( object RoomList : NavTarget @Parcelize - data class Room(val roomId: RoomId) : NavTarget + data class Room( + val roomId: RoomId, + val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages + ) : NavTarget @Parcelize object Settings : NavTarget @@ -176,6 +179,10 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onInvitesClicked() { backstack.push(NavTarget.InviteList) } + + override fun onRoomSettingsClicked(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails)) + } } roomListEntryPoint .nodeBuilder(this, buildContext) @@ -193,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor( } } else { val nodeLifecycleCallbacks = plugins() - val inputs = RoomFlowNode.Inputs(room) + val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement) createNode(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks) } } 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 2b33e37c7e..0493ae7db1 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -59,7 +59,7 @@ class RoomFlowNode @AssistedInject constructor( roomMembershipObserver: RoomMembershipObserver, ) : BackstackNode( backstack = BackStack( - initialElement = NavTarget.Messages, + initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -73,6 +73,7 @@ class RoomFlowNode @AssistedInject constructor( data class Inputs( val room: MatrixRoom, + val initialElement: NavTarget = NavTarget.Messages, ) : NodeInputs private val inputs: Inputs = inputs() @@ -98,6 +99,7 @@ class RoomFlowNode @AssistedInject constructor( navigateUp() } .launchIn(lifecycleScope) + inputs() } private fun fetchRoomMembers() = lifecycleScope.launch { diff --git a/build.gradle.kts b/build.gradle.kts index 3855f48400..8657aa7b4c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,7 +59,7 @@ allprojects { config = files("$rootDir/tools/detekt/detekt.yml") } dependencies { - detektPlugins("io.nlopez.compose.rules:detekt:0.1.6") + detektPlugins("io.nlopez.compose.rules:detekt:0.1.7") } // KtLint diff --git a/changelog.d/427.feature b/changelog.d/427.feature new file mode 100644 index 0000000000..5aad0dcec8 --- /dev/null +++ b/changelog.d/427.feature @@ -0,0 +1 @@ +Room list contextual menu diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml index ec038d764e..6c3a7c8130 100644 --- a/features/createroom/impl/src/main/res/values-de/translations.xml +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -1,6 +1,7 @@ "Neuer Raum" + "Personen einladen" "Personen hinzufügen" "Privater Raum (nur auf Einladung)" "Raumname" diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt index d714eda319..b6e0f59667 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt @@ -161,12 +161,12 @@ fun InviteListContent( @Preview @Composable -internal fun RoomListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = +internal fun InviteListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -internal fun RoomListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = +internal fun InviteListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/leaveroom/api/build.gradle.kts b/features/leaveroom/api/build.gradle.kts new file mode 100644 index 0000000000..83ca28b39a --- /dev/null +++ b/features/leaveroom/api/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.leaveroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrix.api) + ksp(libs.showkase.processor) +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt new file mode 100644 index 0000000000..d1a3369ac6 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt @@ -0,0 +1,26 @@ +/* + * 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.leaveroom.api + +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface LeaveRoomEvent { + data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent + object HideConfirmation : LeaveRoomEvent + data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent + object HideError : LeaveRoomEvent +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt new file mode 100644 index 0000000000..dd1f83691e --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt @@ -0,0 +1,25 @@ +/* + * 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.leaveroom.api + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter + +interface LeaveRoomPresenter : Presenter { + @Composable + override fun present(): LeaveRoomState +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt new file mode 100644 index 0000000000..7cb9926677 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt @@ -0,0 +1,43 @@ +/* + * 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.leaveroom.api + +import io.element.android.libraries.matrix.api.core.RoomId + +data class LeaveRoomState( + val confirmation: Confirmation = Confirmation.Hidden, + val progress: Progress = Progress.Hidden, + val error: Error = Error.Hidden, + val eventSink: (LeaveRoomEvent) -> Unit = {}, +) { + sealed interface Confirmation { + object Hidden : Confirmation + data class Generic(val roomId: RoomId) : Confirmation + data class PrivateRoom(val roomId: RoomId) : Confirmation + data class LastUserInRoom(val roomId: RoomId) : Confirmation + } + + sealed interface Progress { + object Hidden : Progress + object Shown : Progress + } + + sealed interface Error { + object Hidden : Error + object Shown : Error + } +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt new file mode 100644 index 0000000000..e9b08bcd18 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.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.features.leaveroom.api + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId + +class LeaveRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Shown, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Shown, + ), + ) +} + +private val A_ROOM_ID = RoomId("!aRoomId:aDomain") diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt new file mode 100644 index 0000000000..d1746ca917 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt @@ -0,0 +1,131 @@ +/* + * 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.leaveroom.api + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.R +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +fun LeaveRoomView( + state: LeaveRoomState +) { + LeaveRoomConfirmationDialog(state) + LeaveRoomProgressDialog(state) + LeaveRoomErrorDialog(state) +} + +@Composable +private fun LeaveRoomConfirmationDialog( + state: LeaveRoomState, +) { + when (state.confirmation) { + is LeaveRoomState.Confirmation.Hidden -> {} + is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog( + text = StringR.string.leave_room_alert_private_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + + is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog( + text = StringR.string.leave_room_alert_empty_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + + is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog( + text = StringR.string.leave_room_alert_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + } +} + +@Composable +private fun LeaveRoomConfirmationDialog( + @StringRes text: Int, + roomId: RoomId, + eventSink: (LeaveRoomEvent) -> Unit, +) { + ConfirmationDialog( + content = stringResource(text), + submitText = stringResource(R.string.action_leave), + onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, + onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) }, + ) +} + +@Composable +private fun LeaveRoomProgressDialog( + state: LeaveRoomState, +) { + when (state.progress) { + is LeaveRoomState.Progress.Hidden -> {} + is LeaveRoomState.Progress.Shown -> ProgressDialog( + text = stringResource(StringR.string.common_leaving_room), + ) + } +} + +@Composable +private fun LeaveRoomErrorDialog( + state: LeaveRoomState, +) { + when (state.error) { + is LeaveRoomState.Error.Hidden -> {} + is LeaveRoomState.Error.Shown -> ErrorDialog( + content = stringResource(StringR.string.error_unknown), + onDismiss = { state.eventSink(LeaveRoomEvent.HideError) } + ) + } +} + +@Preview +@Composable +internal fun LeaveRoomViewLightPreview( + @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState +) = ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun LeaveRoomViewDarkPreview( + @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState +) = ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LeaveRoomState) { + Box( + modifier = Modifier.size(300.dp, 300.dp), + propagateMinConstraints = true, + ) { + LeaveRoomView(state = state) + } +} diff --git a/features/leaveroom/fake/build.gradle.kts b/features/leaveroom/fake/build.gradle.kts new file mode 100644 index 0000000000..19a057d5ba --- /dev/null +++ b/features/leaveroom/fake/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * 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") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.leaveroom.fake" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + api(projects.features.leaveroom.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.coroutines.core) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) +} diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt new file mode 100644 index 0000000000..28c12b54ba --- /dev/null +++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt @@ -0,0 +1,44 @@ +/* + * 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.leaveroom.fake + +import androidx.compose.runtime.Composable +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +import javax.inject.Inject + +class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter { + + val events = mutableListOf() + + private fun handleEvent(event: LeaveRoomEvent) { + events += event + } + + private var state = LeaveRoomState(eventSink = ::handleEvent) + set(value) { + field = value.copy(eventSink = ::handleEvent) + } + + fun givenState(state: LeaveRoomState) { + this.state = state + } + + @Composable + override fun present(): LeaveRoomState = state +} diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt new file mode 100644 index 0000000000..b20b88db1c --- /dev/null +++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt @@ -0,0 +1,30 @@ +/* + * 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.leaveroom.fake + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.libraries.di.SessionScope + +@Module +@ContributesTo(SessionScope::class) +interface LeaveRoomPresenterFakeModule { + @Binds + fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter +} diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts new file mode 100644 index 0000000000..8d26ea9271 --- /dev/null +++ b/features/leaveroom/impl/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * 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") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.leaveroom.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + api(projects.features.leaveroom.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.coroutines.core) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt new file mode 100644 index 0000000000..4b34e70fb4 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt @@ -0,0 +1,127 @@ +/* + * 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.leaveroom.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +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.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class LeaveRoomPresenterImpl @Inject constructor( + private val client: MatrixClient, + private val roomMembershipObserver: RoomMembershipObserver, + private val dispatchers: CoroutineDispatchers, +) : LeaveRoomPresenter { + @Composable + override fun present(): LeaveRoomState { + val scope = rememberCoroutineScope() + val confirmation = remember { mutableStateOf(LeaveRoomState.Confirmation.Hidden) } + val progress = remember { mutableStateOf(LeaveRoomState.Progress.Hidden) } + val error = remember { mutableStateOf(LeaveRoomState.Error.Hidden) } + + return LeaveRoomState( + confirmation = confirmation.value, + progress = progress.value, + error = error.value, + ) { event -> + when (event) { + is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) { + showLeaveRoomAlert( + matrixClient = client, + roomId = event.roomId, + confirmation = confirmation, + ) + } + + is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden + is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) { + client.leaveRoom( + roomId = event.roomId, + roomMembershipObserver = roomMembershipObserver, + confirmation = confirmation, + progress = progress, + error = error, + ) + } + + is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden + } + } + } +} + +private suspend fun showLeaveRoomAlert( + matrixClient: MatrixClient, + roomId: RoomId, + confirmation: MutableState, +) { + matrixClient.getRoom(roomId)?.use { room -> + confirmation.value = when { + !room.isPublic -> PrivateRoom(roomId) + (room.memberCount() as? Async.Success)?.state == 1 -> LastUserInRoom(roomId) + else -> Generic(roomId) + } + } +} + +private suspend fun MatrixClient.leaveRoom( + roomId: RoomId, + roomMembershipObserver: RoomMembershipObserver, + confirmation: MutableState, + progress: MutableState, + error: MutableState, +) { + confirmation.value = LeaveRoomState.Confirmation.Hidden + progress.value = LeaveRoomState.Progress.Shown + getRoom(roomId)?.use { room -> + room.leave().onSuccess { + roomMembershipObserver.notifyUserLeftRoom(room.roomId) + }.onFailure { + Timber.e(it, "Error while leaving room ${room.name} - ${room.roomId}") + error.value = LeaveRoomState.Error.Shown + } + } + progress.value = LeaveRoomState.Progress.Hidden +} + +private suspend fun MatrixRoom.memberCount(): Async = membersStateFlow.first().let { membersState -> + 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.count { it.membership == RoomMembershipState.JOIN }) + } +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt new file mode 100644 index 0000000000..65403adb60 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt @@ -0,0 +1,30 @@ +/* + * 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.leaveroom.impl + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.libraries.di.SessionScope + +@Module +@ContributesTo(SessionScope::class) +interface LeaveRoomPresenterImplModule { + @Binds + fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter +} diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt new file mode 100644 index 0000000000..5122f34f31 --- /dev/null +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt @@ -0,0 +1,238 @@ +/* + * 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.leaveroom.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +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.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 +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.test.A_ROOM_ID +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.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LeaveRoomPresenterImplTest { + + @Test + fun `present - initial state hides all dialogs`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden) + assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden) + assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden) + } + } + + @Test + fun `present - show generic confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom() + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID)) + } + } + + @Test + fun `present - show private room confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(isPublic = false), + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - show last user in room confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + RoomMember( + userId = UserId(value = "@aUserId:aDomain"), + displayName = null, + avatarUrl = null, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false + ) + ) + ) + ) + }, + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - leaving a room leaves the room`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(), + ) + }, + roomMembershipObserver = roomMembershipObserver + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + // Membership observer should receive a 'left room' change + assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show error if leave room fails`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + givenLeaveRoomError(RuntimeException("Blimey!")) + }, + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + skipItems(1) // Skip show progress state + val errorState = awaitItem() + assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show progress indicator while leaving a room`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(), + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + val progressState = awaitItem() + assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown) + val finalState = awaitItem() + assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden) + } + } + + @Test + fun `present - hide error hides the error`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + givenLeaveRoomError(RuntimeException("Blimey!")) + }, + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + skipItems(1) // Skip show progress state + val errorState = awaitItem() + assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) + skipItems(1) // Skip hide progress state + errorState.eventSink(LeaveRoomEvent.HideError) + val hiddenErrorState = awaitItem() + assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden) + } + } +} + +private fun TestScope.createPresenter( + client: MatrixClient = FakeMatrixClient(), + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), +): LeaveRoomPresenter = LeaveRoomPresenterImpl( + client = client, + roomMembershipObserver = roomMembershipObserver, + dispatchers = testCoroutineDispatchers(testScheduler, false), +) diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index 061f3453df..4c9b232147 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -1,5 +1,9 @@ + "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten." + "Dieser Server unterstützt derzeit kein Sliding Sync." + "Homeserver-URL" + "Sie können nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Ihr Homeserver-Administrator muss dies konfigurieren. %1$s" "Wie lautet die Adresse deines Servers?" "Willkommen zurück!" "Passwort" diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 327746b5a2..26f69ebf81 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(projects.libraries.textcomposer) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.eventformatter.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.mediaupload.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 1919de78f0..e37fd11540 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -19,9 +19,9 @@ package io.element.android.features.messages.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState -import io.element.android.features.messages.impl.timeline.aTimelineItemContent import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.RoomId @@ -46,7 +46,7 @@ fun aMessagesState() = MessagesState( mode = MessageComposerMode.Normal("Hello"), ), timelineState = aTimelineState().copy( - timelineItems = aTimelineItemList(aTimelineItemContent()), + timelineItems = aTimelineItemList(aTimelineItemTextContent()), ), actionListState = anActionListState(), hasNetworkConnection = true, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index be38b7a59c..7ec51dab3f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -39,7 +39,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Collections -import androidx.compose.material.icons.filled.LocalLibrary import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.rememberModalBottomSheetState @@ -69,6 +68,7 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerView +import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView @@ -88,7 +88,6 @@ import io.element.android.libraries.designsystem.utils.LogCompositions import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch import timber.log.Timber - import io.element.android.libraries.ui.strings.R as StringsR @OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @@ -154,6 +153,11 @@ fun MessagesView( } } + fun onExpandGroupClick(event: TimelineItem.GroupedEvents) { + Timber.v("onExpandGroupClick= ${event.id}") + state.timelineState.eventSink(TimelineEvents.ToggleExpandGroup(event)) + } + fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { state.eventSink(MessagesEvents.HandleAction(action, event)) } @@ -203,7 +207,8 @@ fun MessagesView( .padding(padding) .consumeWindowInsets(padding), onMessageClicked = ::onMessageClicked, - onMessageLongClicked = ::onMessageLongClicked + onMessageLongClicked = ::onMessageLongClicked, + onExpandGroupClick = ::onExpandGroupClick, ) }, snackbarHost = { @@ -242,6 +247,7 @@ fun MessagesViewContent( modifier: Modifier = Modifier, onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, + onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {}, ) { Column( modifier = modifier @@ -255,7 +261,8 @@ fun MessagesViewContent( state = state.timelineState, modifier = Modifier.weight(1f), onMessageClicked = onMessageClicked, - onMessageLongClicked = onMessageLongClicked + onMessageLongClicked = onMessageLongClicked, + onExpandGroupClick = onExpandGroupClick, ) } MessageComposerView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index cdffa9c618..7eb3fbe433 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.libraries.architecture.Presenter import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -53,20 +54,25 @@ class ActionListPresenter @Inject constructor() : Presenter { ) } - fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState) = launch { + private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState) = launch { target.value = ActionListState.Target.Loading(timelineItem) val actions = - if (timelineItem.content is TimelineItemRedactedContent) { - emptyList() - } else { - mutableListOf( - TimelineItemAction.Reply, - TimelineItemAction.Forward, - TimelineItemAction.Copy, - ).also { - if (timelineItem.isMine) { - it.add(TimelineItemAction.Edit) - it.add(TimelineItemAction.Redact) + when (timelineItem.content) { + is TimelineItemRedactedContent, + is TimelineItemStateContent -> { + // TODO Add Share action (also) here, and developer options + emptyList() + } + else -> { + mutableListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + ).also { + if (timelineItem.isMine) { + it.add(TimelineItemAction.Edit) + it.add(TimelineItemAction.Redact) + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index ff64441198..11f1a1a483 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -16,9 +16,11 @@ package io.element.android.features.messages.impl.timeline +import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.EventId sealed interface TimelineEvents { object LoadMore : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents + data class ToggleExpandGroup(val event: TimelineItem.GroupedEvents) : TimelineEvents } 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 9418e59edc..1693a623f3 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 @@ -17,15 +17,18 @@ package io.element.android.features.messages.impl.timeline import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateMapOf 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.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimeline @@ -42,6 +45,7 @@ private const val backPaginationPageSize = 50 class TimelinePresenter @Inject constructor( private val timelineItemsFactory: TimelineItemsFactory, + private val timelineItemGrouper: TimelineItemGrouper, room: MatrixRoom, ) : Presenter { @@ -53,6 +57,8 @@ class TimelinePresenter @Inject constructor( val highlightedEventId: MutableState = rememberSaveable { mutableStateOf(null) } + val expandedGroups = remember { mutableStateMapOf() } + val timelineItems = timelineItemsFactory .flow() .collectAsState() @@ -65,6 +71,9 @@ class TimelinePresenter @Inject constructor( when (event) { TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value) is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId + is TimelineEvents.ToggleExpandGroup -> { + expandedGroups[event.event.identifier()] = expandedGroups[event.event.identifier()].orFalse().not() + } } } @@ -83,7 +92,7 @@ class TimelinePresenter @Inject constructor( return TimelineState( highlightedEventId = highlightedEventId.value, paginationState = paginationState.value, - timelineItems = timelineItems.value.toImmutableList(), + timelineItems = timelineItemGrouper.group(timelineItems.value, expandedGroups).toImmutableList(), eventSink = ::handleEvents ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 189c1769ee..4bf5da4c84 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -21,7 +21,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId @@ -55,6 +56,12 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList content = content, groupPosition = TimelineItemGroupPosition.First ), + // A state event on top of it + aTimelineItemEvent( + isMine = false, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ), // 3 items (First Middle Last) with isMine = true aTimelineItemEvent( isMine = true, @@ -71,12 +78,18 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList content = content, groupPosition = TimelineItemGroupPosition.First ), + // A state event on top of it + aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ), ) } internal fun aTimelineItemEvent( isMine: Boolean = false, - content: TimelineItemEventContent = aTimelineItemContent(), + content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First ): TimelineItem.Event { val randomId = "\$" + Random.nextInt().toString() @@ -96,10 +109,3 @@ internal fun aTimelineItemEvent( groupPosition = groupPosition, ) } - -internal fun aTimelineItemContent(): TimelineItemEventContent { - return TimelineItemTextContent( - body = "Text", - htmlDocument = null - ) -} 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 1114ddbf4e..65abc98071 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 @@ -16,8 +16,8 @@ package io.element.android.features.messages.impl.timeline +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -49,19 +49,24 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.components.MessageEventBubble +import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -81,6 +86,7 @@ fun TimelineView( modifier: Modifier = Modifier, onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, + onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {}, ) { fun onReachedLoadMore() { @@ -92,8 +98,6 @@ fun TimelineView( LazyColumn( modifier = Modifier.fillMaxSize(), state = lazyListState, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Bottom, reverseLayout = true ) { itemsIndexed( @@ -103,9 +107,10 @@ fun TimelineView( ) { index, timelineItem -> TimelineItemRow( timelineItem = timelineItem, - isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value, + highlightedItem = state.highlightedEventId?.value, onClick = onMessageClicked, - onLongClick = onMessageLongClicked + onLongClick = onMessageLongClicked, + onExpandGroupClick = onExpandGroupClick, ) if (index == state.timelineItems.lastIndex) { onReachedLoadMore() @@ -124,16 +129,20 @@ fun TimelineView( @Composable fun TimelineItemRow( timelineItem: TimelineItem, - isHighlighted: Boolean, + highlightedItem: String?, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, + onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit, + modifier: Modifier = Modifier ) { when (timelineItem) { - is TimelineItem.Virtual -> TimelineItemVirtualRow( - virtual = timelineItem - ) + is TimelineItem.Virtual -> { + TimelineItemVirtualRow( + virtual = timelineItem, + modifier = modifier, + ) + } is TimelineItem.Event -> { - fun onClick() { onClick(timelineItem) } @@ -142,12 +151,54 @@ fun TimelineItemRow( onLongClick(timelineItem) } - TimelineItemEventRow( - event = timelineItem, - isHighlighted = isHighlighted, - onClick = ::onClick, - onLongClick = ::onLongClick - ) + if (timelineItem.content is TimelineItemStateContent) { + TimelineItemStateEventRow( + event = timelineItem, + isHighlighted = highlightedItem == timelineItem.identifier(), + onClick = ::onClick, + onLongClick = ::onLongClick, + modifier = modifier, + ) + } else { + TimelineItemEventRow( + event = timelineItem, + isHighlighted = highlightedItem == timelineItem.identifier(), + onClick = ::onClick, + onLongClick = ::onLongClick, + modifier = modifier, + ) + } + } + is TimelineItem.GroupedEvents -> { + fun onExpandGroupClick() { + onExpandGroupClick(timelineItem) + } + + Column(modifier = modifier.animateContentSize()) { + GroupHeaderView( + text = pluralStringResource( + id = R.plurals.room_timeline_state_changes, + count = timelineItem.events.size, + timelineItem.events.size + ), + isExpanded = timelineItem.expanded, + isHighlighted = !timelineItem.expanded && timelineItem.events.any { it.identifier() == highlightedItem }, + onClick = ::onExpandGroupClick, + ) + if (timelineItem.expanded) { + Column { + timelineItem.events.forEach { subGroupEvent -> + TimelineItemRow( + timelineItem = subGroupEvent, + highlightedItem = highlightedItem, + onClick = onClick, + onLongClick = onLongClick, + onExpandGroupClick = {} + ) + } + } + } + } } } } @@ -236,6 +287,42 @@ fun TimelineItemEventRow( } } +@Composable +fun TimelineItemStateEventRow( + event: TimelineItem.Event, + isHighlighted: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + MessageStateEventContainer( + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + TimelineItemEventContentView( + content = event.content, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = contentModifier + ) + } + } +} + @Composable private fun MessageSenderInformation( sender: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index b696b63f92..606d185568 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -84,6 +84,7 @@ fun MessageEventBubble( fun Modifier.offsetForItem(): Modifier { return if (state.isMine) { + // FIXME setting y offset to -12.dp can overlap a state event displayed above. offset(y = -(12.dp)) } else { offset(x = 20.dp, y = -(12.dp)) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt new file mode 100644 index 0000000000..fa1faaa93d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -0,0 +1,99 @@ +/* + * 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.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Surface + +private val CORNER_RADIUS = 8.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MessageStateEventContainer( + isHighlighted: Boolean, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + val backgroundColor = if (isHighlighted) { + ElementTheme.colors.messageHighlightedBackground + } else { + Color.Companion.Transparent + } + val shape = RoundedCornerShape(CORNER_RADIUS) + Surface( + modifier = modifier + .widthIn(min = 80.dp) + .clip(shape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = rememberRipple(), + interactionSource = interactionSource + ), + color = backgroundColor, + shape = shape, + content = content + ) +} + +@Preview +@Composable +internal fun MessageStateEventContainerLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun MessageStateEventContainerDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + MessageStateEventContainer( + isHighlighted = false, + interactionSource = MutableInteractionSource(), + ) { + Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) + } + MessageStateEventContainer( + isHighlighted = true, + interactionSource = MutableInteractionSource(), + ) { + Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt index 25f3077971..9c9fb17c5f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt @@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent @@ -74,5 +75,9 @@ fun TimelineItemEventContentView( content = content, modifier = modifier.defaultContentPadding() ) + is TimelineItemStateContent -> TimelineItemStateView( + content = content, + modifier = modifier + ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt new file mode 100644 index 0000000000..1402d59fd6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.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.features.messages.impl.timeline.components.event + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun TimelineItemStateView( + content: TimelineItemStateContent, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + color = MaterialTheme.colorScheme.secondary, + fontSize = 13.sp, + text = content.body, + textAlign = TextAlign.Center, + ) +} + +@Preview +@Composable +internal fun TimelineItemStateViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemStateViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemStateView( + content = aTimelineItemStateEventContent(), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt new file mode 100644 index 0000000000..e747134405 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt @@ -0,0 +1,134 @@ +/* + * 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.messages.impl.timeline.components.group + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text + +private val CORNER_RADIUS = 8.dp + +@Composable +fun GroupHeaderView( + text: String, + isExpanded: Boolean, + isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val backgroundColor = if (isHighlighted) { + ElementTheme.colors.messageHighlightedBackground + } else { + Color.Companion.Transparent + } + val shape = RoundedCornerShape(CORNER_RADIUS) + + Box( + modifier = modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .clip(shape) + .clickable(onClick = onClick), + color = backgroundColor, + shape = shape, + ) { + Row( + modifier = Modifier + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.secondary, + fontSize = 13.sp + ) + val icon = if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + } + Icon(icon, "", tint = MaterialTheme.colorScheme.secondary) + } + } + } +} + +@Preview +@Composable +fun GroupHeaderViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun GroupHeaderViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + GroupHeaderView( + text = "8 room changes (expanded)", + isExpanded = true, + isHighlighted = false, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (not expanded)", + isExpanded = false, + isHighlighted = false, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (expanded/h)", + isExpanded = true, + isHighlighted = true, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (not expanded/h)", + isExpanded = false, + isHighlighted = true, + onClick = {} + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 527de007b1..eb6d0e45c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent @@ -26,7 +27,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent -import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import javax.inject.Inject @@ -43,15 +43,15 @@ class TimelineItemContentFactory @Inject constructor( private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory ) { - fun create(itemContent: EventContent): TimelineItemEventContent { - return when (itemContent) { + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + return when (val itemContent = eventTimelineItem.content) { is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is MessageContent -> messageFactory.create(itemContent) - is ProfileChangeContent -> profileChangeFactory.create(itemContent) + is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem) is RedactedContent -> redactedMessageFactory.create(itemContent) - is RoomMembershipContent -> roomMembershipFactory.create(itemContent) - is StateContent -> stateFactory.create(itemContent) + is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) + is StateContent -> stateFactory.create(eventTimelineItem) is StickerContent -> stickerFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent) is UnknownContent -> TimelineItemUnknownContent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt index 689bbcbaa1..e54d88326d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt @@ -17,13 +17,18 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent -import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import javax.inject.Inject -class TimelineItemContentProfileChangeFactory @Inject constructor() { +class TimelineItemContentProfileChangeFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { - fun create(content: ProfileChangeContent): TimelineItemEventContent { - return TimelineItemUnknownContent + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemProfileChangeContent(text.orEmpty().toString()) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt index 808e0d3b70..d5cf0cec2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt @@ -17,13 +17,18 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent -import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import javax.inject.Inject -class TimelineItemContentRoomMembershipFactory @Inject constructor() { +class TimelineItemContentRoomMembershipFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { - fun create(content: RoomMembershipContent): TimelineItemEventContent { - return TimelineItemUnknownContent + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemRoomMembershipContent(text.orEmpty().toString()) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt index 1680006bc1..072b568af9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt @@ -17,13 +17,18 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent -import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import javax.inject.Inject -class TimelineItemContentStateFactory @Inject constructor() { +class TimelineItemContentStateFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { - fun create(content: StateContent): TimelineItemEventContent { - return TimelineItemUnknownContent + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemStateEventContent(text.orEmpty().toString()) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index c21622b50f..a69fb8ddcb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -67,7 +67,7 @@ class TimelineItemEventFactory @Inject constructor( senderId = currentSender, senderDisplayName = senderDisplayName, senderAvatar = senderAvatarData, - content = contentFactory.create(currentTimelineItem.event.content), + content = contentFactory.create(currentTimelineItem.event), isMine = currentTimelineItem.event.isOwn, groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt new file mode 100644 index 0000000000..3cd1b8fd4a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -0,0 +1,95 @@ +/* + * 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.messages.impl.timeline.groups + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.core.bool.orFalse +import kotlinx.collections.immutable.toImmutableList + +import javax.inject.Inject + +class TimelineItemGrouper @Inject constructor() { + /** + * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. + */ + fun group(from: List, expandedGroups: Map): List { + val result = mutableListOf() + val currentGroup = mutableListOf() + from.forEach { timelineItem -> + if (timelineItem is TimelineItem.Event && timelineItem.canBeGrouped()) { + currentGroup.add(0, timelineItem) + } else { + // timelineItem cannot be grouped + if (currentGroup.isNotEmpty()) { + // There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. + result.addGroup(currentGroup, expandedGroups) + currentGroup.clear() + } + result.add(timelineItem) + } + } + if (currentGroup.isNotEmpty()) { + result.addGroup(currentGroup, expandedGroups) + } + return result + } + + private fun TimelineItem.Event.canBeGrouped(): Boolean { + return when (content) { + is TimelineItemEncryptedContent, + is TimelineItemImageContent, + TimelineItemRedactedContent, + is TimelineItemEmoteContent, + is TimelineItemNoticeContent, + is TimelineItemTextContent, + TimelineItemUnknownContent -> false + is TimelineItemProfileChangeContent, + is TimelineItemRoomMembershipContent, + is TimelineItemStateEventContent -> true + } + } +} + +/** + * Will add a group if there is more than 1 item, else add the item to the list. + */ +private fun MutableList.addGroup( + group: MutableList, + expandedGroups: Map, +) { + if (group.size == 1) { + // Do not create a group with just 1 item, just add the item to the result + add(group.first()) + } else { + add( + TimelineItem.GroupedEvents( + expanded = expandedGroups[group.first().id + "_group"].orFalse(), + events = group.toImmutableList() + ) + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index e1b6557f31..fe34cd5b1d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList @Immutable sealed interface TimelineItem { @@ -29,11 +30,13 @@ sealed interface TimelineItem { fun identifier(): String = when (this) { is Event -> id is Virtual -> id + is GroupedEvents -> id } fun contentType(): String = when (this) { is Event -> content.type is Virtual -> model.type + is GroupedEvents -> "groupedEvent" } @Immutable @@ -60,4 +63,13 @@ sealed interface TimelineItem { val safeSenderName: String = senderDisplayName ?: senderId.value } + + @Immutable + data class GroupedEvents( + val expanded: Boolean, + val events: ImmutableList, + ) : TimelineItem { + // use first id with a suffix + val id = events.first().id + "_group" + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 30b5f29ea0..6c680bf8fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -68,3 +68,7 @@ fun aTimelineItemTextContent() = TimelineItemTextContent( ) fun aTimelineItemUnknownContent() = TimelineItemUnknownContent + +fun aTimelineItemStateEventContent() = TimelineItemStateEventContent( + body = "A state event", +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt new file mode 100644 index 0000000000..7d56394893 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt @@ -0,0 +1,23 @@ +/* + * 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.impl.timeline.model.event + +data class TimelineItemProfileChangeContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemProfileChangeContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt new file mode 100644 index 0000000000..93607f01e6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt @@ -0,0 +1,23 @@ +/* + * 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.impl.timeline.model.event + +data class TimelineItemRoomMembershipContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemRoomMembershipContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt new file mode 100644 index 0000000000..b136a602b2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt @@ -0,0 +1,21 @@ +/* + * 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.messages.impl.timeline.model.event + +sealed interface TimelineItemStateContent : TimelineItemEventContent { + val body: String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt new file mode 100644 index 0000000000..1c656c9b96 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt @@ -0,0 +1,23 @@ +/* + * 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.impl.timeline.model.event + +data class TimelineItemStateEventContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemStateEventContent" +} diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..7cd4b6e764 --- /dev/null +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + + "%1$d cambio en la sala" + "%1$d cambios en la sala" + + \ No newline at end of file diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..649a91405b --- /dev/null +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + + "%1$d modifica alla stanza" + "%1$d modifiche alla stanza" + + \ No newline at end of file diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..68d83cacfe --- /dev/null +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ + + + + "%1$d schimbare a camerii" + "%1$d schimbări ale camerei" + "%1$d schimbări ale camerei" + + \ No newline at end of file diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 8c53bca6c4..361e80a4de 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -1,5 +1,9 @@ + + "%1$d room change" + "%1$d room changes" + "Camera" "Take photo" "Record a video" 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 f2691ca1a9..14807e3c7c 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 @@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -141,6 +142,7 @@ class MessagesPresenterTest { ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), + timelineItemGrouper = TimelineItemGrouper(), room = matrixRoom, ) val actionListPresenter = ActionListPresenter() 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 058406d513..5d355be8d0 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 @@ -31,26 +31,39 @@ 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.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.tests.testutils.testCoroutineDispatchers -internal fun aTimelineItemsFactory() = TimelineItemsFactory( - dispatchers = testCoroutineDispatchers(), - eventItemFactory = TimelineItemEventFactory( - TimelineItemContentFactory( - messageFactory = TimelineItemContentMessageFactory(), - redactedMessageFactory = TimelineItemContentRedactedFactory(), - stickerFactory = TimelineItemContentStickerFactory(), - utdFactory = TimelineItemContentUTDFactory(), - roomMembershipFactory = TimelineItemContentRoomMembershipFactory(), - profileChangeFactory = TimelineItemContentProfileChangeFactory(), - stateFactory = TimelineItemContentStateFactory(), - failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), - failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() - ) - ), - virtualItemFactory = TimelineItemVirtualFactory( - daySeparatorFactory = TimelineItemDaySeparatorFactory( - FakeDaySeparatorFormatter() +internal fun aTimelineItemsFactory(): TimelineItemsFactory { + val timelineEventFormatter = aTimelineEventFormatter() + return TimelineItemsFactory( + dispatchers = testCoroutineDispatchers(), + eventItemFactory = TimelineItemEventFactory( + TimelineItemContentFactory( + messageFactory = TimelineItemContentMessageFactory(), + redactedMessageFactory = TimelineItemContentRedactedFactory(), + stickerFactory = TimelineItemContentStickerFactory(), + utdFactory = TimelineItemContentUTDFactory(), + roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), + profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), + stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), + failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() + ) ), + virtualItemFactory = TimelineItemVirtualFactory( + daySeparatorFactory = TimelineItemDaySeparatorFactory( + FakeDaySeparatorFormatter() + ), + ) ) -) +} + +internal fun aTimelineEventFormatter(): TimelineEventFormatter { + return object : TimelineEventFormatter { + override fun format(event: EventTimelineItem): CharSequence { + return "" + } + } +} 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 40ca7def0a..01c6e0eb49 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.messages.textcomposer import app.cash.molecule.RecompositionClock @@ -21,11 +23,11 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.SnackbarDispatcher @@ -52,6 +54,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.textcomposer.MessageComposerMode import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test import java.io.File 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 a88b2251a4..7b13a93a5a 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 @@ -23,8 +23,13 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import kotlinx.coroutines.test.runTest import org.junit.Test @@ -33,6 +38,7 @@ class TimelinePresenterTest { fun `present - initial state`() = runTest { val presenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), + timelineItemGrouper = TimelineItemGrouper(), room = FakeMatrixRoom(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -49,6 +55,7 @@ class TimelinePresenterTest { fun `present - load more`() = runTest { val presenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), + timelineItemGrouper = TimelineItemGrouper(), room = FakeMatrixRoom(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -71,6 +78,7 @@ class TimelinePresenterTest { fun `present - set highlighted event`() = runTest { val presenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), + timelineItemGrouper = TimelineItemGrouper(), room = FakeMatrixRoom(), ) moleculeFlow(RecompositionClock.Immediate) { @@ -87,4 +95,37 @@ class TimelinePresenterTest { assertThat(withoutHighlightedState.highlightedEventId).isNull() } } + + @Test + fun `present - expand and collapse grouped events`() = runTest { + val fakeTimeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */), + MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */), + ) + ) + val fakeRoom = FakeMatrixRoom(matrixTimeline = fakeTimeline) + val presenter = TimelinePresenter( + timelineItemsFactory = aTimelineItemsFactory(), + timelineItemGrouper = TimelineItemGrouper(), + room = fakeRoom, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + fakeTimeline.updateTimelineItems { it } + val loadedState = awaitItem() + val group1 = loadedState.timelineItems.first() as TimelineItem.GroupedEvents + assertThat(group1.expanded).isFalse() + loadedState.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group1)) + val withExpandedGroup = awaitItem() + val group2 = withExpandedGroup.timelineItems.first() as TimelineItem.GroupedEvents + assertThat(group2.expanded).isTrue() + withExpandedGroup.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group2)) + val withCollapsedGroup = awaitItem() + val group3 = withCollapsedGroup.timelineItems.first() as TimelineItem.GroupedEvents + assertThat(group3.expanded).isFalse() + } + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt new file mode 100644 index 0000000000..5829bbad9f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -0,0 +1,170 @@ +/* + * 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.messages.timeline.groups + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class TimelineItemGrouperTest { + private val sut = TimelineItemGrouper() + + private val aGroupableItem = TimelineItem.Event( + id = AN_EVENT_ID.value, + senderId = A_USER_ID, + senderAvatar = anAvatarData(), + senderDisplayName = "", + content = TimelineItemStateEventContent(body = "a state event"), + reactionsState = TimelineItemReactions(emptyList().toImmutableList()) + ) + private val aNonGroupableItem = aMessageEvent() + private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) + + @Test + fun `test empty`() { + val result = sut.group(emptyList(), emptyMap()) + assertThat(result).isEmpty() + } + + @Test + fun `test non groupables`() { + val result = sut.group( + listOf( + aNonGroupableItem, + aNonGroupableItem, + ), + emptyMap() + ) + assertThat(result).isEqualTo( + listOf( + aNonGroupableItem, + aNonGroupableItem, + ) + ) + } + + @Test + fun `test groupables and ensure reordering`() { + val result = sut.group( + listOf( + aGroupableItem.copy(id = AN_EVENT_ID_2.value), + aGroupableItem, + ), + emptyMap() + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + expanded = false, + events = listOf( + aGroupableItem, + aGroupableItem.copy(id = AN_EVENT_ID_2.value), + ).toImmutableList() + ), + ) + ) + } + + @Test + fun `test groupables expanded`() { + val result = sut.group( + listOf( + aGroupableItem, + aGroupableItem.copy(id = AN_EVENT_ID_2.value), + ), + mapOf("${AN_EVENT_ID_2.value}_group" to true) + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + expanded = true, + events = listOf( + aGroupableItem.copy(id = AN_EVENT_ID_2.value), + aGroupableItem, + ).toImmutableList() + ), + ) + ) + } + + @Test + fun `test 1 groupable, not group must be created`() { + val listsToTest = listOf( + listOf(aGroupableItem), + listOf(aGroupableItem, aNonGroupableItem), + listOf(aGroupableItem, aNonGroupableItemNoEvent), + listOf(aNonGroupableItem, aGroupableItem), + listOf(aNonGroupableItemNoEvent, aGroupableItem), + listOf(aNonGroupableItem, aGroupableItem, aNonGroupableItem), + listOf(aNonGroupableItemNoEvent, aGroupableItem, aNonGroupableItemNoEvent), + listOf(aGroupableItem, aNonGroupableItem, aGroupableItem), + listOf(aGroupableItem, aNonGroupableItemNoEvent, aGroupableItem), + listOf(aNonGroupableItem), + listOf(aNonGroupableItemNoEvent), + ) + listsToTest.forEach { listToTest -> + val result = sut.group(listToTest, emptyMap()) + assertThat(result).isEqualTo(listToTest) + } + } + + @Test + fun `test 3 blocks`() { + val result = sut.group( + listOf( + aGroupableItem, + aGroupableItem, + aNonGroupableItem, + aGroupableItem, + aGroupableItem, + aGroupableItem, + ), + emptyMap() + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + expanded = false, + events = listOf( + aGroupableItem, + aGroupableItem, + ).toImmutableList() + ), + aNonGroupableItem, + TimelineItem.GroupedEvents( + expanded = false, + events = listOf( + aGroupableItem, + aGroupableItem, + aGroupableItem, + ).toImmutableList() + ) + ) + ) + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt index 869f0412cd..eb49eb450e 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt @@ -31,10 +31,21 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.mockk.mockk import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass import org.junit.Test -import kotlin.time.Duration.Companion.seconds class RageshakeDetectionPresenterTest { + + companion object { + private lateinit var aBitmap: Bitmap + + @BeforeClass + @JvmStatic + fun initBitmap() { + aBitmap = mockk() + } + } + @Test fun `present - initial state`() = runTest { val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) @@ -85,7 +96,7 @@ class RageshakeDetectionPresenterTest { } @Test - fun `present - screenshot with success then dismiss`() = runTest(timeout = 30.seconds) { + fun `present - screenshot with success then dismiss`() = runTest { val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val rageshake = FakeRageShake(isAvailableValue = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) @@ -108,7 +119,7 @@ class RageshakeDetectionPresenterTest { rageshake.triggerPhoneRageshake() assertThat(awaitItem().takeScreenshot).isTrue() initialState.eventSink.invoke( - RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) ) assertThat(awaitItem().showDialog).isTrue() initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) @@ -153,7 +164,7 @@ class RageshakeDetectionPresenterTest { } @Test - fun `present - screenshot then disable`() = runTest(timeout = 1.seconds) { + fun `present - screenshot then disable`() = runTest { val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val rageshake = FakeRageShake(isAvailableValue = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) @@ -176,7 +187,7 @@ class RageshakeDetectionPresenterTest { rageshake.triggerPhoneRageshake() assertThat(awaitItem().takeScreenshot).isTrue() initialState.eventSink.invoke( - RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) ) assertThat(awaitItem().showDialog).isTrue() initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) @@ -187,5 +198,3 @@ class RageshakeDetectionPresenterTest { } } -private fun aBitmap(): Bitmap = mockk() - diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 0470fc2e65..bcd618eccd 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { api(projects.libraries.usersearch.api) api(projects.services.apperror.api) implementation(libs.coil.compose) + implementation(projects.features.leaveroom.api) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) @@ -54,6 +55,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.tests.testutils) + testImplementation(projects.features.leaveroom.fake) ksp(libs.showkase.processor) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index 3ef87d17e0..b7bb31757e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -17,7 +17,5 @@ package io.element.android.features.roomdetails.impl sealed interface RoomDetailsEvent { - data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent - object ClearLeaveRoomWarning : RoomDetailsEvent - object ClearError : RoomDetailsEvent + object LeaveRoom : RoomDetailsEvent } 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 01562841ea..4e7d88b18e 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,44 +18,33 @@ 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 io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter 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.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 io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.ui.room.getDirectRoomMember -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import javax.inject.Inject class RoomDetailsPresenter @Inject constructor( private val room: MatrixRoom, - private val roomMembershipObserver: RoomMembershipObserver, - private val coroutineDispatchers: CoroutineDispatchers, private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, + private val leaveRoomPresenter: LeaveRoomPresenter, ) : Presenter { @Composable override fun present(): RoomDetailsState { - val coroutineScope = rememberCoroutineScope() - val leaveRoomWarning = remember { - mutableStateOf(null) - } - val error = remember { - mutableStateOf(null) - } + val leaveRoomState = leaveRoomPresenter.present() LaunchedEffect(Unit) { room.updateMembers() } @@ -69,16 +58,8 @@ class RoomDetailsPresenter @Inject constructor( fun handleEvents(event: RoomDetailsEvent) { when (event) { - is RoomDetailsEvent.LeaveRoom -> { - coroutineScope.leaveRoom( - needsConfirmation = event.needsConfirmation, - memberCount = memberCount, - leaveRoomWarning = leaveRoomWarning, - error = error, - ) - } - RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null - RoomDetailsEvent.ClearError -> error.value = null + is RoomDetailsEvent.LeaveRoom -> + leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId)) } } @@ -93,10 +74,9 @@ class RoomDetailsPresenter @Inject constructor( memberCount = memberCount, isEncrypted = room.isEncrypted, canInvite = canInvite, - displayLeaveRoomWarning = leaveRoomWarning.value, - error = error.value, roomType = roomType.value, roomMemberDetailsState = roomMemberDetailsState, + leaveRoomState = leaveRoomState, eventSink = ::handleEvents, ) } @@ -141,27 +121,4 @@ class RoomDetailsPresenter @Inject constructor( } } } - - 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 90fa48c575..a046548b19 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,6 +16,7 @@ package io.element.android.features.roomdetails.impl +import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomMember @@ -28,11 +29,10 @@ data class RoomDetailsState( val roomTopic: String?, val memberCount: Async, val isEncrypted: Boolean, - val displayLeaveRoomWarning: LeaveRoomWarning?, - val error: RoomDetailsError?, val roomType: RoomDetailsType, val roomMemberDetailsState: RoomMemberDetailsState?, val canInvite: Boolean, + val leaveRoomState: LeaveRoomState, val eventSink: (RoomDetailsEvent) -> Unit ) @@ -40,23 +40,3 @@ sealed interface RoomDetailsType { object Room : RoomDetailsType data class Dm(val roomMember: RoomMember) : RoomDetailsType } - -sealed class LeaveRoomWarning { - object Generic : LeaveRoomWarning() - object PrivateRoom : LeaveRoomWarning() - object LastUserInRoom : LeaveRoomWarning() - - companion object { - fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async): LeaveRoomWarning { - return when { - !isPublic -> PrivateRoom - (memberCount as? Async.Success)?.state == 1 -> LastUserInRoom - else -> Generic - } - } - } -} - -sealed interface RoomDetailsError { - object AlertGeneric : RoomDetailsError -} 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 08da243487..9cb5b925fa 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 @@ -17,6 +17,7 @@ package io.element.android.features.roomdetails.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.UserId @@ -70,11 +71,10 @@ fun aRoomDetailsState() = RoomDetailsState( "|| MAI iki/Marketing...", memberCount = Async.Success(32), isEncrypted = true, - displayLeaveRoomWarning = null, - error = null, canInvite = false, roomType = RoomDetailsType.Room, roomMemberDetailsState = null, + leaveRoomState = LeaveRoomState(), 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 293f290112..7d90342f65 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 @@ -47,6 +47,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection +import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection import io.element.android.libraries.architecture.isLoading @@ -56,8 +57,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.MainActionButton -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -68,7 +67,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -97,6 +95,8 @@ fun RoomDetailsView( .consumeWindowInsets(padding) .verticalScroll(rememberScrollState()) ) { + LeaveRoomView(state = state.leaveRoomState) + when (state.roomType) { RoomDetailsType.Room -> { RoomHeaderSection( @@ -145,23 +145,8 @@ fun RoomDetailsView( } OtherActionsSection(onLeaveRoom = { - state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + state.eventSink(RoomDetailsEvent.LeaveRoom) }) - - if (state.displayLeaveRoomWarning != null) { - ConfirmLeaveRoomDialog( - leaveRoomWarning = state.displayLeaveRoomWarning, - onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) }, - onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) } - ) - } - - if (state.error != null) { - ErrorDialog( - content = stringResource(StringR.string.error_unknown), - onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) } - ) - } } } } @@ -260,27 +245,6 @@ internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = M } } -@Composable -internal fun ConfirmLeaveRoomDialog( - leaveRoomWarning: LeaveRoomWarning, - onConfirmLeave: () -> Unit, - onDismiss: () -> Unit -) { - val content = stringResource( - when (leaveRoomWarning) { - LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle - LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle - LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle - } - ) - ConfirmationDialog( - content = content, - submitText = stringResource(StringR.string.action_leave), - onSubmitClicked = onConfirmLeave, - onDismiss = onDismiss, - ) -} - @LargeHeightPreview @Composable fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml index 7581b585f1..4e5b3e9a74 100644 --- a/features/roomdetails/impl/src/main/res/values-de/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -4,11 +4,13 @@ "1 Person" "%1$d Personen" + "Bereits eingeladen" "Raum teilen" "Blockieren" "Nutzer blockieren" "Blockierung aufheben" "Nutzer entblockieren" + "Personen einladen" "Raum verlassen" "Sicherheit" "Thema" 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 8e97c42936..0c950071ca 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 @@ -20,8 +20,7 @@ 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.impl.LeaveRoomWarning -import io.element.android.features.roomdetails.impl.RoomDetailsEvent +import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.members.aRoomMember @@ -34,7 +33,6 @@ 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.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipState -import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange 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 @@ -44,9 +42,6 @@ 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 -import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.runTest import org.junit.Test @@ -62,7 +57,7 @@ class RoomDetailsPresenterTests { return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId) } } - return RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers, roomMemberDetailsPresenterFactory) + return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake()) } @Test @@ -155,103 +150,6 @@ class RoomDetailsPresenterTests { } } - @Test - fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { - val room = aMatrixRoom(isPublic = false).apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) - } - val presenter = aRoomDetailsPresenter(room) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) - val confirmationState = awaitItem() - Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) - } - } - - @Test - fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { - val room = aMatrixRoom().apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember()))) - } - val presenter = aRoomDetailsPresenter(room) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) - val confirmationState = awaitItem() - Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) - } - } - - @Test - fun `present - Leave with confirmation shows a generic warning`() = runTest { - val room = aMatrixRoom().apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) - } - val presenter = aRoomDetailsPresenter(room) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) - val confirmationState = awaitItem() - Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) - } - } - - @Test - fun `present - Leave without confirmation leaves the room`() = runTest { - val room = aMatrixRoom().apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList())) - } - val presenter = aRoomDetailsPresenter(room) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) - - cancelAndIgnoreRemainingEvents() - } - - // Membership observer should receive a 'left room' change - roomMembershipObserver.updates.take(1) - .onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) } - .collect() - } - - @Test - fun `present - ClearError removes any error present`() = runTest { - val room = aMatrixRoom().apply { - givenLeaveRoomError(Throwable()) - } - val presenter = aRoomDetailsPresenter(room) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - skipItems(1) - - initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) - val errorState = awaitItem() - Truth.assertThat(errorState.error).isNotNull() - errorState.eventSink(RoomDetailsEvent.ClearError) - Truth.assertThat(awaitItem().error).isNull() - } - } - @Test fun `present - initial state when user can invite others to room`() = runTest { val room = aMatrixRoom().apply { diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt index dd4347ba0c..eeefa15c02 100644 --- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -36,6 +36,7 @@ interface RoomListEntryPoint : FeatureEntryPoint { fun onSettingsClicked() fun onSessionVerificationClicked() fun onInvitesClicked() + fun onRoomSettingsClicked(roomId: RoomId) } } diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 9150ff4c95..c1ee491e7f 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -48,8 +48,10 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.eventformatter.api) implementation(projects.features.invitelist.api) implementation(projects.features.networkmonitor.api) + implementation(projects.features.leaveroom.api) implementation(libs.accompanist.placeholder) api(projects.features.roomlist.api) ksp(libs.showkase.processor) @@ -62,10 +64,12 @@ dependencies { testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.permissions.noop) testImplementation(projects.features.invitelist.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.tests.testutils) + testImplementation(projects.features.leaveroom.fake) androidTestImplementation(libs.test.junitext) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt deleted file mode 100644 index e2971c93d7..0000000000 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatter.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * 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 - -import android.content.Context -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem -import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent -import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent -import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange -import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent -import io.element.android.libraries.matrix.api.timeline.item.event.MessageType -import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.OtherState -import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent -import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails -import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent -import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent -import io.element.android.libraries.matrix.api.timeline.item.event.StateContent -import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent -import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent -import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent -import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType -import timber.log.Timber -import javax.inject.Inject -import io.element.android.libraries.ui.strings.R as StringR - -@ContributesBinding(SessionScope::class) -class DefaultRoomLastMessageFormatter @Inject constructor( - // TODO replace with StringProvider - @ApplicationContext private val context: Context, - private val matrixClient: MatrixClient, -) : RoomLastMessageFormatter { - - override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { - val isOutgoing = event.sender == matrixClient.sessionId - val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value - return when (val content = event.content) { - is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom) - RedactedContent -> { - val message = context.getString(StringR.string.common_message_removed) - if (!isDmRoom) { - prefix(message, senderDisplayName) - } else { - message - } - } - is StickerContent -> { - content.body - } - is UnableToDecryptContent -> { - val message = context.getString(StringR.string.common_decryption_error) - if (!isDmRoom) { - prefix(message, senderDisplayName) - } else { - message - } - } - is RoomMembershipContent -> { - processRoomMembershipChange(content, senderDisplayName, isOutgoing) - } - is ProfileChangeContent -> { - processProfileChangeContent(content, senderDisplayName, isOutgoing) - } - is StateContent -> { - processRoomStateChange(content, senderDisplayName, isOutgoing) - } - is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { - prefixIfNeeded(context.getString(StringR.string.common_unsupported_event), senderDisplayName, isDmRoom) - } - } - } - - private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? { - val messageType: MessageType = messageContent.type ?: return null - - val internalMessage = when (messageType) { - // Doesn't need a prefix - is EmoteMessageType -> { - return "- $senderDisplayName ${messageType.body}" - } - is TextMessageType -> { - messageType.body - } - is VideoMessageType -> { - context.getString(StringR.string.common_video) - } - is ImageMessageType -> { - context.getString(StringR.string.common_image) - } - is FileMessageType -> { - context.getString(StringR.string.common_file) - } - is AudioMessageType -> { - context.getString(StringR.string.common_audio) - } - UnknownMessageType -> { - context.getString(StringR.string.common_unsupported_event) - } - is NoticeMessageType -> { - messageType.body - } - } - return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom) - } - - private fun processRoomMembershipChange(membershipContent: RoomMembershipContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? { - val userId = membershipContent.userId - val memberIsYou = userId == matrixClient.sessionId - return when (val change = membershipContent.change) { - MembershipChange.JOINED -> if (memberIsYou) { - context.getString(R.string.state_event_room_join_by_you) - } else { - context.getString(R.string.state_event_room_join, userId.value) - } - MembershipChange.LEFT -> if (memberIsYou) { - context.getString(R.string.state_event_room_leave_by_you) - } else { - context.getString(R.string.state_event_room_leave, userId.value) - } - MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { - context.getString(R.string.state_event_room_ban_by_you, userId.value) - } else { - context.getString(R.string.state_event_room_ban, senderDisplayName, userId.value) - } - MembershipChange.UNBANNED -> if (senderIsYou) { - context.getString(R.string.state_event_room_unban_by_you, userId.value) - } else { - context.getString(R.string.state_event_room_unban, senderDisplayName, userId.value) - } - MembershipChange.KICKED -> if (senderIsYou) { - context.getString(R.string.state_event_room_remove_by_you, userId.value) - } else { - context.getString(R.string.state_event_room_remove, senderDisplayName, userId.value) - } - MembershipChange.INVITED -> if (senderIsYou) { - context.getString(R.string.state_event_room_invite_by_you, userId.value) - } else if (memberIsYou) { - context.getString(R.string.state_event_room_invite_you, senderDisplayName) - } else { - context.getString(R.string.state_event_room_invite, senderDisplayName, userId.value) - } - MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { - context.getString(R.string.state_event_room_invite_accepted_by_you) - } else { - context.getString(R.string.state_event_room_invite_accepted, userId.value) - } - MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { - context.getString(R.string.state_event_room_reject_by_you) - } else { - context.getString(R.string.state_event_room_reject, userId.value) - } - MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { - context.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value) - } else { - context.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value) - } - MembershipChange.KNOCKED -> if (memberIsYou) { - context.getString(R.string.state_event_room_knock_by_you) - } else { - context.getString(R.string.state_event_room_knock, userId.value) - } - MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { - context.getString(R.string.state_event_room_knock_accepted_by_you, userId.value) - } else { - context.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value) - } - MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { - context.getString(R.string.state_event_room_knock_retracted_by_you) - } else { - context.getString(R.string.state_event_room_knock_retracted, userId.value) - } - MembershipChange.KNOCK_DENIED -> if (senderIsYou) { - context.getString(R.string.state_event_room_knock_denied_by_you, userId.value) - } else if (memberIsYou) { - context.getString(R.string.state_event_room_knock_denied_you, senderDisplayName) - } else { - context.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value) - } - else -> { - Timber.v("Filtering timeline item for room membership: $membershipContent") - null - } - } - } - - private fun processRoomStateChange(stateContent: StateContent, senderDisplayName: String, senderIsYou: Boolean): CharSequence? { - return when (val content = stateContent.content) { - is OtherState.RoomAvatar -> { - val hasAvatarUrl = content.url != null - when { - senderIsYou && hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_changed_by_you) - senderIsYou && !hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_removed_by_you) - !senderIsYou && hasAvatarUrl -> context.getString(R.string.state_event_room_avatar_changed, senderDisplayName) - else -> context.getString(R.string.state_event_room_avatar_removed, senderDisplayName) - } - } - is OtherState.RoomCreate -> { - if (senderIsYou) { - context.getString(R.string.state_event_room_created_by_you) - } else { - context.getString(R.string.state_event_room_created, senderDisplayName) - } - } - is OtherState.RoomEncryption -> context.getString(StringR.string.common_encryption_enabled) - is OtherState.RoomName -> { - val hasRoomName = content.name != null - when { - senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed_by_you, content.name) - senderIsYou && !hasRoomName -> context.getString(R.string.state_event_room_name_removed_by_you) - !senderIsYou && hasRoomName -> context.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name) - else -> context.getString(R.string.state_event_room_name_removed, senderDisplayName) - } - } - is OtherState.RoomThirdPartyInvite -> { - if (content.displayName == null) { - Timber.e("RoomThirdPartyInvite undisplayable due to missing name") - return null - } - if (senderIsYou) { - context.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName) - } else { - context.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName) - } - } - is OtherState.RoomTopic -> { - val hasRoomTopic = content.topic != null - when { - senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed_by_you, content.topic) - senderIsYou && !hasRoomTopic -> context.getString(R.string.state_event_room_topic_removed_by_you) - !senderIsYou && hasRoomTopic -> context.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic) - else -> context.getString(R.string.state_event_room_topic_removed, senderDisplayName) - } - } - else -> { - Timber.v("Filtering timeline item for room state change: $content") - null - } - } - } - - private fun processProfileChangeContent( - profileChangeContent: ProfileChangeContent, - senderDisplayName: String, - senderIsYou: Boolean - ): String? = profileChangeContent.run { - val displayNameChanged = displayName != prevDisplayName - val avatarChanged = avatarUrl != prevAvatarUrl - return when { - avatarChanged && displayNameChanged -> { - val message = processProfileChangeContent(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou) - val avatarChangedToo = context.getString(R.string.state_event_avatar_changed_too) - "$message\n$avatarChangedToo" - } - displayNameChanged -> { - if (displayName != null && prevDisplayName != null) { - if (senderIsYou) { - context.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName) - } else { - context.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName) - } - } else if (displayName != null) { - if (senderIsYou) { - context.getString(R.string.state_event_display_name_set_by_you, displayName) - } else { - context.getString(R.string.state_event_display_name_set, senderDisplayName, displayName) - } - } else { - if (senderIsYou) { - context.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName) - } else { - context.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName) - } - } - } - avatarChanged -> { - if (senderIsYou) { - context.getString(R.string.state_event_avatar_url_changed_by_you) - } else { - context.getString(R.string.state_event_avatar_url_changed, senderDisplayName) - } - } - else -> null - } - } - - private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) { - message - } else { - prefix(message, senderDisplayName) - } - - private fun prefix(message: String, senderDisplayName: String): AnnotatedString { - return buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(senderDisplayName) - } - append(": ") - append(message) - } - } -} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt new file mode 100644 index 0000000000..4e172bbbfe --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt @@ -0,0 +1,148 @@ +/* + * 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 + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomListContextMenu( + contextMenu: RoomListState.ContextMenu.Shown, + eventSink: (RoomListEvents) -> Unit, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, + ) { + RoomListModalBottomSheetContent( + contextMenu = contextMenu, + onRoomSettingsClicked = { + eventSink(RoomListEvents.HideContextMenu) + onRoomSettingsClicked(it) + }, + onLeaveRoomClicked = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) + } + ) + } +} + +@Composable +private fun RoomListModalBottomSheetContent( + contextMenu: RoomListState.ContextMenu.Shown, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, + onLeaveRoomClicked: (roomId: RoomId) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + ListItem( + headlineContent = { + Text( + text = contextMenu.roomName, + fontWeight = FontWeight.Bold, + ) + } + ) + ListItem( + headlineContent = { + Text(text = stringResource(id = StringR.string.common_settings)) + }, + modifier = Modifier.clickable { onRoomSettingsClicked(contextMenu.roomId) }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(id = StringR.string.common_settings), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = StringR.string.action_leave_room), + color = ElementTheme.colors.textActionCritical, + ) + }, + modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) }, + leadingContent = { + Icon( + resourceId = VectorIcons.DoorOpen, + contentDescription = stringResource(id = StringR.string.action_leave_room), + modifier = Modifier.size(20.dp), + tint = ElementTheme.colors.textActionCritical, + ) + } + ) + Spacer(modifier = Modifier.height(32.dp)) + } +} + +// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up. +// see: https://issuetracker.google.com/issues/283843380 +// Remove this preview when the issue is fixed. +@Preview +@Composable +internal fun RoomListModalBottomSheetContentLightPreview() = + ElementPreviewLight { ContentToPreview() } + +// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up. +// see: https://issuetracker.google.com/issues/283843380 +// Remove this preview when the issue is fixed. +@Preview +@Composable +internal fun RoomListModalBottomSheetContentDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + RoomListModalBottomSheetContent( + contextMenu = RoomListState.ContextMenu.Shown( + roomId = RoomId(value = "!aRoom:aDomain"), + roomName = "aRoom" + ), + onRoomSettingsClicked = {}, + onLeaveRoomClicked = {} + ) +} 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 684342bee8..e95b5bd60d 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 @@ -16,9 +16,15 @@ package io.element.android.features.roomlist.impl +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.matrix.api.core.RoomId + sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents object DismissRequestVerificationPrompt : RoomListEvents object ToggleSearchResults : RoomListEvents + data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents + object HideContextMenu : RoomListEvents + data class LeaveRoom(val roomId: RoomId) : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index 7641f015cc..50a7a7bfbe 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -56,17 +56,22 @@ class RoomListNode @AssistedInject constructor( plugins().forEach { it.onInvitesClicked() } } + private fun onRoomSettingsClicked(roomId: RoomId) { + plugins().forEach { it.onRoomSettingsClicked(roomId) } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() RoomListView( state = state, - modifier = modifier, onRoomClicked = this::onRoomClicked, - onOpenSettings = this::onOpenSettings, + onSettingsClicked = this::onOpenSettings, onCreateRoomClicked = this::onCreateRoomClicked, onVerifyClicked = this::onSessionVerificationClicked, onInvitesClicked = this::onInvitesClicked, + onRoomSettingsClicked = this::onRoomSettingsClicked, + modifier = modifier, ) } } 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 59faa5afbe..9448ff4b1b 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 @@ -28,6 +28,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.libraries.architecture.Presenter @@ -37,6 +39,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -62,10 +65,12 @@ class RoomListPresenter @Inject constructor( private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, private val inviteStateDataSource: InviteStateDataSource, + private val leaveRoomPresenter: LeaveRoomPresenter, ) : Presenter { @Composable override fun present(): RoomListState { + val leaveRoomState = leaveRoomPresenter.present() val matrixUser: MutableState = rememberSaveable { mutableStateOf(null) } @@ -97,6 +102,8 @@ class RoomListPresenter @Inject constructor( var displaySearchResults by rememberSaveable { mutableStateOf(false) } + var contextMenu by remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } + fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateFilter -> filter = event.newFilter @@ -108,6 +115,14 @@ class RoomListPresenter @Inject constructor( } displaySearchResults = !displaySearchResults } + is RoomListEvents.ShowContextMenu -> { + contextMenu = RoomListState.ContextMenu.Shown( + roomId = event.roomListRoomSummary.roomId, + roomName = event.roomListRoomSummary.name + ) + } + is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden + is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) } } @@ -132,6 +147,8 @@ class RoomListPresenter @Inject constructor( hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, invitesState = inviteStateDataSource.inviteState(), displaySearchResults = displaySearchResults, + contextMenu = contextMenu, + leaveRoomState = leaveRoomState, eventSink = ::handleEvents ) } @@ -183,7 +200,7 @@ class RoomListPresenter @Inject constructor( hasUnread = roomSummary.details.unreadNotificationCount > 0, timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), lastMessage = roomSummary.details.lastMessage?.let { message -> - roomLastMessageFormatter.processMessageItem(message.event, roomSummary.details.isDirect) + roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) }.orEmpty(), avatarData = avatarData, ) 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 0f8de1046d..7905b5bc61 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 @@ -17,8 +17,10 @@ package io.element.android.features.roomlist.impl import androidx.compose.runtime.Immutable +import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -33,8 +35,18 @@ data class RoomListState( val snackbarMessage: SnackbarMessage?, val invitesState: InvitesState, val displaySearchResults: Boolean, - val eventSink: (RoomListEvents) -> Unit -) + val contextMenu: ContextMenu, + val leaveRoomState: LeaveRoomState, + val eventSink: (RoomListEvents) -> Unit, +) { + sealed interface ContextMenu { + object Hidden : ContextMenu + data class Shown( + val roomId: RoomId, + val roomName: String, + ) : ContextMenu + } +} enum class InvitesState { NoInvites, 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 8bd6537d52..c5598b5426 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 @@ -17,6 +17,7 @@ package io.element.android.features.roomlist.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -39,6 +40,9 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState().copy(invitesState = InvitesState.NewInvites), aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), aRoomListState().copy(displaySearchResults = true), + aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown( + roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name" + )) ) } @@ -52,6 +56,8 @@ internal fun aRoomListState() = RoomListState( displayVerificationPrompt = false, invitesState = InvitesState.NoInvites, displaySearchResults = false, + contextMenu = RoomListState.ContextMenu.Hidden, + leaveRoomState = LeaveRoomState(), 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 dc3c7bff7a..49b2c3604c 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 @@ -16,7 +16,6 @@ package io.element.android.features.roomlist.impl -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -62,6 +61,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomSummaryRow @@ -88,20 +88,38 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun RoomListView( state: RoomListState, + onRoomClicked: (RoomId) -> Unit, + onSettingsClicked: () -> Unit, + onVerifyClicked: () -> Unit, + onCreateRoomClicked: () -> Unit, + onInvitesClicked: () -> Unit, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, modifier: Modifier = Modifier, - onRoomClicked: (RoomId) -> Unit = {}, - onOpenSettings: () -> Unit = {}, - onVerifyClicked: () -> Unit = {}, - onCreateRoomClicked: () -> Unit = {}, - onInvitesClicked: () -> Unit = {}, ) { Column(modifier = modifier) { ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) Box { + fun onRoomLongClicked( + roomListRoomSummary: RoomListRoomSummary + ) { + state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary)) + } + + if (state.contextMenu is RoomListState.ContextMenu.Shown) { + RoomListContextMenu( + contextMenu = state.contextMenu, + eventSink = state.eventSink, + onRoomSettingsClicked = onRoomSettingsClicked, + ) + } + + LeaveRoomView(state = state.leaveRoomState) + RoomListContent( state = state, onRoomClicked = onRoomClicked, - onOpenSettings = onOpenSettings, + onRoomLongClicked = { onRoomLongClicked(it) }, + onOpenSettings = onSettingsClicked, onVerifyClicked = onVerifyClicked, onCreateRoomClicked = onCreateRoomClicked, onInvitesClicked = onInvitesClicked, @@ -110,6 +128,7 @@ fun RoomListView( RoomListSearchResultView( state = state, onRoomClicked = onRoomClicked, + onRoomLongClicked = { onRoomLongClicked(it) }, modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) @@ -125,12 +144,12 @@ fun RoomListContent( modifier: Modifier = Modifier, onVerifyClicked: () -> Unit = {}, onRoomClicked: (RoomId) -> Unit = {}, + onRoomLongClicked: (RoomListRoomSummary) -> Unit = {}, onOpenSettings: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {}, onInvitesClicked: () -> Unit = {}, ) { fun onRoomClicked(room: RoomListRoomSummary) { - if (room.roomId == null) return onRoomClicked(room.roomId) } @@ -237,7 +256,11 @@ fun RoomListContent( items = state.roomList, contentType = { room -> room.contentType() }, ) { room -> - RoomSummaryRow(room = room, onClick = ::onRoomClicked) + RoomSummaryRow( + room = room, + onClick = ::onRoomClicked, + onLongClick = onRoomLongClicked, + ) } } } @@ -339,13 +362,25 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl @Composable private fun ContentToPreview(state: RoomListState) { - RoomListView(state) + RoomListView( + state = state, + onRoomClicked = {}, + onSettingsClicked = {}, + onVerifyClicked = {}, + onCreateRoomClicked = {}, + onInvitesClicked = {}, + onRoomSettingsClicked = {} + ) } @Preview @Composable internal fun RoomListSearchResultContentPreview() { ElementPreviewLight { - RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {}) + RoomListSearchResultContent( + state = aRoomListState(), + onRoomClicked = {}, + onRoomLongClicked = {} + ) } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt index 0709b41ef2..67847977e6 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -16,8 +16,9 @@ package io.element.android.features.roomlist.impl.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -70,17 +71,20 @@ import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator private val minHeight = 72.dp +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun RoomSummaryRow( room: RoomListRoomSummary, + onClick: (RoomListRoomSummary) -> Unit, + onLongClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, - onClick: (RoomListRoomSummary) -> Unit = {}, ) { val clickModifier = if (room.isPlaceholder) { modifier } else { - modifier.clickable( + modifier.combinedClickable( onClick = { onClick(room) }, + onLongClick = { onLongClick(room) }, indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() } ) @@ -214,5 +218,9 @@ internal fun RoomSummaryRowDarkPreview(@PreviewParameter(RoomListRoomSummaryProv @Composable private fun ContentToPreview(data: RoomListRoomSummary) { - RoomSummaryRow(data) + RoomSummaryRow( + room = data, + onClick = {}, + onLongClick = {} + ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt index d938cce04f..734d1ce9e1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt @@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId @Immutable data class RoomListRoomSummary( val id: String, - val roomId: RoomId?, + val roomId: RoomId, val name: String = "", val hasUnread: Boolean = false, val timestamp: String? = null, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt index d6de1544cb..47e3a8ca28 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt @@ -17,13 +17,14 @@ package io.element.android.features.roomlist.impl.model import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.RoomId object RoomListRoomSummaryPlaceholders { fun create(id: String): RoomListRoomSummary { return RoomListRoomSummary( id = id, - roomId = null, + roomId = RoomId("!aRoom:domain"), isPlaceholder = true, name = "Short name", timestamp = "hh:mm", 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 index bdd4858ce3..f70c46c18b 100644 --- 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 @@ -70,6 +70,7 @@ import io.element.android.libraries.ui.strings.R internal fun RoomListSearchResultView( state: RoomListState, onRoomClicked: (RoomId) -> Unit, + onRoomLongClicked: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -85,7 +86,11 @@ internal fun RoomListSearchResultView( }) ) { if (state.displaySearchResults) { - RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked) + RoomListSearchResultContent( + state = state, + onRoomClicked = onRoomClicked, + onRoomLongClicked = onRoomLongClicked, + ) } } } @@ -96,6 +101,7 @@ internal fun RoomListSearchResultView( internal fun RoomListSearchResultContent( state: RoomListState, onRoomClicked: (RoomId) -> Unit, + onRoomLongClicked: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { val borderColor = MaterialTheme.colorScheme.tertiary @@ -104,7 +110,6 @@ internal fun RoomListSearchResultContent( state.eventSink(RoomListEvents.ToggleSearchResults) } fun onRoomClicked(room: RoomListRoomSummary) { - if (room.roomId == null) return onRoomClicked(room.roomId) } Scaffold( @@ -197,7 +202,11 @@ internal fun RoomListSearchResultContent( items = state.filteredRoomList, contentType = { room -> room.contentType() }, ) { room -> - RoomSummaryRow(room = room, onClick = ::onRoomClicked) + RoomSummaryRow( + room = room, + onClick = ::onRoomClicked, + onLongClick = onRoomLongClicked, + ) } } } diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml index 00b1431f00..be0109cbe2 100644 --- a/features/roomlist/impl/src/main/res/values-de/translations.xml +++ b/features/roomlist/impl/src/main/res/values-de/translations.xml @@ -1,40 +1,4 @@ "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/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml index 079ffb2e72..7edd6192a1 100644 --- a/features/roomlist/impl/src/main/res/values-es/translations.xml +++ b/features/roomlist/impl/src/main/res/values-es/translations.xml @@ -4,58 +4,4 @@ "Todos los chats" "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." "Accede a tu historial de mensajes" - "(el avatar también cambió)" - "%1$s cambió su avatar" - "Cambiaste tu avatar" - "%1$s cambió su nombre de %2$s a %3$s" - "Cambiaste tu nombre de %1$s a %2$s" - "%1$s eliminó su nombre (era %2$s)" - "Eliminaste tu nombre (era %1$s)" - "%1$s cambió su nombre a %2$s" - "Cambiaste tu nombre a %1$s" - "%1$s cambió el avatar de la sala" - "Cambiaste el avatar de la sala" - "%1$s eliminó el avatar de la sala" - "Eliminaste el avatar de la sala" - "%1$s expulsó permanentemente a %2$s" - "Expulsaste permanentemente a %1$s" - "%1$s creó la sala" - "Tú creaste la sala" - "%1$s invitó a %2$s" - "%1$s aceptó la invitación" - "Aceptaste la invitación" - "Invitaste a %1$s" - "%1$s te invitó." - "%1$s se unió a la sala" - "Te uniste a la sala" - "%1$s solicitó unirse" - "%1$s permitió que %2$s se uniera" - "%1$s te permitió unirte" - "Solicitaste unirte" - "%1$s rechazó la solicitud de %2$s para unirse" - "Rechazaste la solicitud de %1$s para unirte" - "%1$s rechazó su solicitud para unirte" - "%1$s ya no está interesado en unirse" - "Cancelaste tu solicitud de unirte" - "%1$s salió de la sala" - "Saliste de la sala" - "%1$s cambió el nombre de la sala a: %2$s" - "Cambiaste el nombre de la sala a: %1$s" - "%1$s eliminó el nombre de la sala" - "Eliminaste el nombre de la sala" - "%1$s rechazó la invitación" - "Rechazaste la invitación" - "%1$s echó a %2$s" - "Echaste a %1$s" - "%1$s envió una invitación a %2$s para unirse a la sala" - "Enviaste una invitación a %1$s para unirse a la sala" - "%1$s revocó la invitación a %2$s para unirse a la sala" - "Revocaste la invitación de %1$s para unirse a la sala" - "%1$s cambió el tema a: %2$s" - "Cambiaste el tema a: %1$s" - "%1$s eliminó el tema de la sala" - "Eliminaste el tema de la sala" - "%1$s readmitió a %2$s" - "Readmitiste a %1$s" - "%1$s realizó un cambio desconocido en su membresía" \ No newline at end of file diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml index 20bf487937..6bfb8baa0c 100644 --- a/features/roomlist/impl/src/main/res/values-it/translations.xml +++ b/features/roomlist/impl/src/main/res/values-it/translations.xml @@ -4,58 +4,4 @@ "Tutte le conversazioni" "Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati." "Accedi alla cronologia dei messaggi" - "(anche l\'avatar è stato cambiato)" - "%1$s ha cambiato il proprio avatar" - "Hai cambiato il tuo avatar" - "%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s" - "Hai cambiato il tuo nome visualizzato da %1$s a %2$s" - "%1$s ha rimosso il proprio nome visualizzato (era %2$s)" - "Hai rimosso il tuo nome visualizzato (era %1$s)" - "%1$s ha impostato il proprio nome visualizzato su %2$s" - "Hai impostato il tuo nome visualizzato su %1$s" - "%1$s ha cambiato l\'avatar della stanza" - "Hai cambiato l\'avatar della stanza" - "%1$s ha rimosso l\'avatar della stanza" - "Hai rimosso l\'avatar della stanza" - "%1$s ha rimosso %2$s" - "Hai rimosso %1$s" - "%1$s ha creato la stanza" - "Hai creato la stanza" - "%1$s ha invitato %2$s" - "%1$s ha accettato l\'invito" - "Hai accettato l\'invito" - "Hai invitato %1$s" - "%1$s ti ha invitato" - "%1$s si è unito alla stanza" - "Ti sei unito alla stanza" - "%1$s ha chiesto di unirsi" - "%1$s ha permesso a %2$s di unirsi" - "%1$s ti ha permesso di unirti" - "Hai richiesto di unirti" - "%1$s ha rifiutato la richiesta di unirsi di %2$s" - "Hai rifiutato la richiesta di unirsi di %1$s" - "%1$s ha rifiutato la tua richiesta di unirti" - "%1$s non è più interessato a partecipare" - "Hai annullato la tua richiesta di unirti" - "%1$s ha lasciato la stanza" - "Hai lasciato la stanza" - "%1$s ha cambiato il nome della stanza in: %2$s" - "Hai cambiato il nome della stanza in: %1$s" - "%1$s ha rimosso il nome della stanza" - "Hai rimosso il nome della stanza" - "%1$s ha rifiutato l\'invito" - "Hai rifiutato l\'invito" - "%1$s ha rimosso %2$s" - "Hai rimosso %1$s" - "%1$s ha inviato un invito a %2$s per unirsi alla stanza" - "Hai inviato un invito a %1$s per unirsi alla stanza" - "%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza." - "Hai revocato l\'invito a %1$s a universi alla stanza" - "%1$s ha cambiato l\'oggetto in: %2$s" - "Hai cambiato l\'oggetto in: %1$s" - "%1$s ha rimosso l\'oggetto della stanza" - "Hai rimosso l\'oggetto della stanza" - "%1$s ha sbloccato %2$s" - "Hai sbloccato %1$s" - "%1$s ha apportato una modifica sconosciuta alla propria iscrizione" \ No newline at end of file diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml index 89760b3497..7401b30b82 100644 --- a/features/roomlist/impl/src/main/res/values-ro/translations.xml +++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml @@ -4,58 +4,4 @@ "Toate conversatiile" "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate." "Accesați istoricul mesajelor" - "(s-a schimbat si avatarul)" - "%1$s și-a schimbat avatarul" - "V-ați schimbat avatarul" - "%1$s și-a schimbat numele din %2$s în %3$s" - "V-ați schimbat numele din %1$s în %2$s" - "%1$s și-a sters numele (era %2$s)" - "V-ați sters numele (era %1$s)" - "%1$s și-a schimbat numele %2$s" - "V-ați schimbat numele în %1$s" - "%1$s a schimbat avatarul camerei" - "Ați schimbat avatarul camerei" - "%1$s a șters avatarul camerei" - "Ați șters avatarul camerei" - "%1$s a adăugat o interdicție pentru %2$s" - "Ați adăugat o interdicție pentru %1$s" - "%1$s a creat camera" - "Ați creat camera" - "%1$s l-a invitat pe %2$s" - "%1$s a acceptat invitația" - "Ați acceptat invitația" - "L-ați invitat pe %1$s" - "%1$s v-a invitat" - "%1$s a intrat în cameră" - "Ați intrat în cameră" - "%1$s a solicitat să se alăture camerei" - "%1$s i-a permis lui %2$s să se alăture camerei" - "%1$s v-a permis să vă alăturați camerei" - "Ați solicitat să vă alăturați camerei" - "%1$s a respins solicitarea de alăturare a lui %2$s" - "Ați respins solicitarea de alăturare a lui %1$s" - "%1$s a respins cererea dumneavoastră de alăturare" - "%1$s nu mai este interesat să se alăture camerei" - "Ați anulat cererea de alăturare" - "%1$s a părăsit camera" - "Ați părăsit camera" - "%1$s a schimbat numele camerei în: %2$s" - "Ați schimbat numele camerei în: %1$s" - "%1$s a sters numele camerei" - "Ați șters numele camerei" - "%1$s a respins invitația" - "Ați respins invitația" - "%1$s l-a îndepărtat pe %2$s" - "L-ați îndepărtat pe %1$s" - "%1$s a trimis o invitație către %2$s pentru a se alătura camerei" - "Ați trimis o invitație către %1$s pentru a se alătura camerei" - "%1$s a revocat invitația pentru %2$s de a se alătura camerei" - "Ați revocat invitația pentru %1$s de a se alătura camerei" - "%1$s a schimbat subiectul în: %2$s" - "Ați schimbat subiectul în: %1$s" - "%1$s a șters subiectul camerei" - "Ați șters subiectul camerei" - "%1$s a anulat interdicția pentru %2$s" - "Ați anulat interdicția pentru %1$s" - "%1$s a făcut o modificare necunoscută asupra calității sale de membru" \ No newline at end of file diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml index 7177e3156d..613e6681ae 100644 --- a/features/roomlist/impl/src/main/res/values/localazy.xml +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -4,58 +4,4 @@ "All Chats" "Looks like you’re using a new device. Verify it’s you to access your encrypted messages." "Access your message history" - "(avatar was changed too)" - "%1$s changed their avatar" - "You changed your avatar" - "%1$s changed their display name from %2$s to %3$s" - "You changed your display name from %1$s to %2$s" - "%1$s removed their display name (it was %2$s)" - "You removed your display name (it was %1$s)" - "%1$s set their display name to %2$s" - "You set your display name to %1$s" - "%1$s changed the room avatar" - "You changed the room avatar" - "%1$s removed the room avatar" - "You removed the room avatar" - "%1$s banned %2$s" - "You banned %1$s" - "%1$s created the room" - "You created the room" - "%1$s invited %2$s" - "%1$s accepted the invite" - "You accepted the invite" - "You invited %1$s" - "%1$s invited you" - "%1$s joined the room" - "You joined the room" - "%1$s requested to join" - "%1$s allowed %2$s to join" - "%1$s allowed you to join" - "You requested to join" - "%1$s rejected %2$s\'s request to join" - "You rejected %1$s\'s request to join" - "%1$s rejected your request to join" - "%1$s is no longer interested in joining" - "You cancelled your request to join" - "%1$s left the room" - "You left the room" - "%1$s changed the room name to: %2$s" - "You changed the room name to: %1$s" - "%1$s removed the room name" - "You removed the room name" - "%1$s rejected the invitation" - "You rejected the invitation" - "%1$s removed %2$s" - "You removed %1$s" - "%1$s sent an invitation to %2$s to join the room" - "You sent an invitation to %1$s to join the room" - "%1$s revoked the invitation for %2$s to join the room" - "You revoked the invitation for %1$s to join the room" - "%1$s changed the topic to: %2$s" - "You changed the topic to: %1$s" - "%1$s removed the room topic" - "You removed the room topic" - "%1$s unbanned %2$s" - "You unbanned %1$s" - "%1$s made an unknown change to their membership" \ 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 104c419f74..fe76d9837b 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 @@ -20,12 +20,16 @@ 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.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -54,6 +58,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -82,6 +87,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -103,6 +109,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -132,6 +139,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -164,6 +172,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -202,6 +211,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -253,6 +263,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -278,6 +289,7 @@ class RoomListPresenterTests { FakeNetworkMonitor(), SnackbarDispatcher(), FakeInviteDataSource(inviteStateFlow), + LeaveRoomPresenterFake(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -297,6 +309,89 @@ class RoomListPresenterTests { } } + @Test + fun `present - show context menu`() = runTest { + val presenter = RoomListPresenter( + FakeMatrixClient(A_SESSION_ID), + createDateFormatter(), + FakeRoomLastMessageFormatter(), + FakeSessionVerificationService(), + FakeNetworkMonitor(), + SnackbarDispatcher(), + FakeInviteDataSource(), + LeaveRoomPresenterFake(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + val summary = aRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + val shownState = awaitItem() + Truth.assertThat(shownState.contextMenu) + .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name)) + } + } + + @Test + fun `present - hide context menu`() = runTest { + val presenter = RoomListPresenter( + FakeMatrixClient(A_SESSION_ID), + createDateFormatter(), + FakeRoomLastMessageFormatter(), + FakeSessionVerificationService(), + FakeNetworkMonitor(), + SnackbarDispatcher(), + FakeInviteDataSource(), + LeaveRoomPresenterFake(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + val summary = aRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + val shownState = awaitItem() + Truth.assertThat(shownState.contextMenu) + .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name)) + shownState.eventSink(RoomListEvents.HideContextMenu) + + val hiddenState = awaitItem() + Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden) + } + } + + @Test + fun `present - leave room calls into leave room presenter`() = runTest { + val leaveRoomPresenter = LeaveRoomPresenterFake() + val presenter = RoomListPresenter( + FakeMatrixClient(A_SESSION_ID), + createDateFormatter(), + FakeRoomLastMessageFormatter(), + FakeSessionVerificationService(), + FakeNetworkMonitor(), + SnackbarDispatcher(), + FakeInviteDataSource(), + leaveRoomPresenter, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID)) + + Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + } + } + private fun createDateFormatter(): LastMessageTimestampFormatter { return FakeLastMessageTimestampFormatter().apply { givenFormat(A_FORMATTED_DATE) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e77870160..49823363bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ datastore = "1.0.0" constraintlayout = "2.1.4" recyclerview = "1.3.0" lifecycle = "2.6.1" -activity = "1.7.1" +activity = "1.7.2" startup = "1.1.1" media3 = "1.0.2" @@ -46,7 +46,7 @@ telephoto = "0.3.0" # DI dagger = "2.46.1" -anvil = "2.4.5" +anvil = "2.4.6" # quality detekt = "1.22.0" @@ -55,6 +55,8 @@ dependencygraph = "0.10" [libraries] # Project android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } +# https://developer.android.com/studio/write/java8-support#library-desugaring-versions +android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3" 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:32.0.0" @@ -153,7 +155,7 @@ anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref = anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" } # Composer -wysiwyg = "io.element.android:wysiwyg:2.1.0" +wysiwyg = "io.element.android:wysiwyg:2.2.1" # Miscellaneous # Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt index a11b0b96bb..c2a82f0e21 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt @@ -22,4 +22,5 @@ object VectorIcons { val Delete = R.drawable.ic_baseline_delete_outline_24 val Reply = R.drawable.ic_baseline_reply_24 val Edit = R.drawable.ic_baseline_edit_24 + val DoorOpen = R.drawable.ic_door_open_24 } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt new file mode 100644 index 0000000000..0c98caaa7d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -0,0 +1,103 @@ +/* + * 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.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = BottomSheetDefaults.Elevation, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + content: @Composable ColumnScope.() -> Unit, +) { + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + scrimColor = scrimColor, + dragHandle = dragHandle, + content = content, + ) +} + +// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetLightPreview() = + ElementPreviewLight { ContentToPreview() } + +// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview() { + Box( + modifier = Modifier.fillMaxSize(), + ) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + skipHiddenState = true, + ), + ) { + Text( + text = "Sheet Content", + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp) + .background(color = Color.Green) + ) + } + } +} diff --git a/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml b/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml new file mode 100644 index 0000000000..7d2eec40f5 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/eventformatter/api/build.gradle.kts b/libraries/eventformatter/api/build.gradle.kts new file mode 100644 index 0000000000..ec2a56d780 --- /dev/null +++ b/libraries/eventformatter/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * 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-library") +} + +android { + namespace = "io.element.android.libraries.eventformatter.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt similarity index 84% rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt rename to libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt index bd59d68592..4dd5978bc6 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomLastMessageFormatter.kt +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package io.element.android.features.roomlist.impl +package io.element.android.libraries.eventformatter.api import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem interface RoomLastMessageFormatter { - fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? + fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? } diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt new file mode 100644 index 0000000000..6a966f1aba --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt @@ -0,0 +1,23 @@ +/* + * 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.eventformatter.api + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +interface TimelineEventFormatter { + fun format(event: EventTimelineItem): CharSequence? +} diff --git a/libraries/eventformatter/impl/build.gradle.kts b/libraries/eventformatter/impl/build.gradle.kts new file mode 100644 index 0000000000..3bee2df488 --- /dev/null +++ b/libraries/eventformatter/impl/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.eventformatter.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) + api(projects.libraries.eventformatter.api) + + testImplementation(projects.services.toolbox.impl) + testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt new file mode 100644 index 0000000000..327c18cb34 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.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.eventformatter.impl + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR + +@ContributesBinding(SessionScope::class) +class DefaultRoomLastMessageFormatter @Inject constructor( + private val sp: StringProvider, + private val matrixClient: MatrixClient, + private val roomMembershipContentFormatter: RoomMembershipContentFormatter, + private val profileChangeContentFormatter: ProfileChangeContentFormatter, + private val stateContentFormatter: StateContentFormatter, +) : RoomLastMessageFormatter { + + override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + val isOutgoing = event.sender == matrixClient.sessionId + val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value + return when (val content = event.content) { + is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom) + RedactedContent -> { + val message = sp.getString(StringR.string.common_message_removed) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is StickerContent -> { + content.body + } + is UnableToDecryptContent -> { + val message = sp.getString(StringR.string.common_decryption_error) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList) + } + is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { + prefixIfNeeded(sp.getString(StringR.string.common_unsupported_event), senderDisplayName, isDmRoom) + } + } + } + + private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? { + val messageType: MessageType = messageContent.type ?: return null + + val internalMessage = when (messageType) { + // Doesn't need a prefix + is EmoteMessageType -> { + return "- $senderDisplayName ${messageType.body}" + } + is TextMessageType -> { + messageType.body + } + is VideoMessageType -> { + sp.getString(StringR.string.common_video) + } + is ImageMessageType -> { + sp.getString(StringR.string.common_image) + } + is FileMessageType -> { + sp.getString(StringR.string.common_file) + } + is AudioMessageType -> { + sp.getString(StringR.string.common_audio) + } + UnknownMessageType -> { + sp.getString(StringR.string.common_unsupported_event) + } + is NoticeMessageType -> { + messageType.body + } + } + return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom) + } + + private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) { + message + } else { + prefix(message, senderDisplayName) + } + + private fun prefix(message: String, senderDisplayName: String): AnnotatedString { + return buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(senderDisplayName) + } + append(": ") + append(message) + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt new file mode 100644 index 0000000000..4bcae94c1e --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -0,0 +1,78 @@ +/* + * 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.eventformatter.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.ui.strings.R +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultTimelineEventFormatter @Inject constructor( + private val sp: StringProvider, + private val matrixClient: MatrixClient, + private val buildMeta: BuildMeta, + private val roomMembershipContentFormatter: RoomMembershipContentFormatter, + private val profileChangeContentFormatter: ProfileChangeContentFormatter, + private val stateContentFormatter: StateContentFormatter, +) : TimelineEventFormatter { + + override fun format(event: EventTimelineItem): CharSequence? { + val isOutgoing = event.sender == matrixClient.sessionId + val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value + return when (val content = event.content) { + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline) + } + RedactedContent, + is StickerContent, + is UnableToDecryptContent, + is MessageContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + is UnknownContent -> { + if (buildMeta.isDebuggable) { + error("You should not use this formatter for this event: $event") + } + sp.getString(R.string.common_unsupported_event) + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt new file mode 100644 index 0000000000..aea43298f9 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.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.eventformatter.impl + +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +class ProfileChangeContentFormatter @Inject constructor( + private val sp: StringProvider, +) { + fun format( + profileChangeContent: ProfileChangeContent, + senderDisplayName: String, + senderIsYou: Boolean, + ): String? = profileChangeContent.run { + val displayNameChanged = displayName != prevDisplayName + val avatarChanged = avatarUrl != prevAvatarUrl + return when { + avatarChanged && displayNameChanged -> { + val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou) + val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too) + "$message\n$avatarChangedToo" + } + displayNameChanged -> { + if (displayName != null && prevDisplayName != null) { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName) + } else { + sp.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName) + } + } else if (displayName != null) { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_set_by_you, displayName) + } else { + sp.getString(R.string.state_event_display_name_set, senderDisplayName, displayName) + } + } else { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName) + } else { + sp.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName) + } + } + } + avatarChanged -> { + if (senderIsYou) { + sp.getString(R.string.state_event_avatar_url_changed_by_you) + } else { + sp.getString(R.string.state_event_avatar_url_changed, senderDisplayName) + } + } + else -> null + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt new file mode 100644 index 0000000000..b5402454f3 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt @@ -0,0 +1,113 @@ +/* + * 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.eventformatter.impl + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject + +class RoomMembershipContentFormatter @Inject constructor( + private val matrixClient: MatrixClient, + private val sp: StringProvider, +) { + fun format( + membershipContent: RoomMembershipContent, + senderDisplayName: String, + senderIsYou: Boolean, + ): CharSequence? { + val userId = membershipContent.userId + val memberIsYou = userId == matrixClient.sessionId + return when (val change = membershipContent.change) { + MembershipChange.JOINED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_join_by_you) + } else { + sp.getString(R.string.state_event_room_join, userId.value) + } + MembershipChange.LEFT -> if (memberIsYou) { + sp.getString(R.string.state_event_room_leave_by_you) + } else { + sp.getString(R.string.state_event_room_leave, userId.value) + } + MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_ban_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_ban, senderDisplayName, userId.value) + } + MembershipChange.UNBANNED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_unban_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_unban, senderDisplayName, userId.value) + } + MembershipChange.KICKED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_remove_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_remove, senderDisplayName, userId.value) + } + MembershipChange.INVITED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_invite_by_you, userId.value) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_invite_you, senderDisplayName) + } else { + sp.getString(R.string.state_event_room_invite, senderDisplayName, userId.value) + } + MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_invite_accepted_by_you) + } else { + sp.getString(R.string.state_event_room_invite_accepted, userId.value) + } + MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_reject_by_you) + } else { + sp.getString(R.string.state_event_room_reject, userId.value) + } + MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value) + } + MembershipChange.KNOCKED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_by_you) + } else { + sp.getString(R.string.state_event_room_knock, userId.value) + } + MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value) + } + MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_retracted_by_you) + } else { + sp.getString(R.string.state_event_room_knock_retracted, userId.value) + } + MembershipChange.KNOCK_DENIED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_denied_you, senderDisplayName) + } else { + sp.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value) + } + else -> { + Timber.v("Filtering timeline item for room membership: $membershipContent") + null + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt new file mode 100644 index 0000000000..169ec55a9d --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt @@ -0,0 +1,219 @@ +/* + * 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.eventformatter.impl + +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject + +class StateContentFormatter @Inject constructor( + private val sp: StringProvider, +) { + fun format( + stateContent: StateContent, + senderDisplayName: String, + senderIsYou: Boolean, + renderingMode: RenderingMode, + ): CharSequence? { + return when (val content = stateContent.content) { + is OtherState.RoomAvatar -> { + val hasAvatarUrl = content.url != null + when { + senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed_by_you) + senderIsYou && !hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_removed_by_you) + !senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisplayName) + else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisplayName) + } + } + is OtherState.RoomCreate -> { + if (senderIsYou) { + sp.getString(R.string.state_event_room_created_by_you) + } else { + sp.getString(R.string.state_event_room_created, senderDisplayName) + } + } + is OtherState.RoomEncryption -> sp.getString(io.element.android.libraries.ui.strings.R.string.common_encryption_enabled) + is OtherState.RoomName -> { + val hasRoomName = content.name != null + when { + senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed_by_you, content.name) + senderIsYou && !hasRoomName -> sp.getString(R.string.state_event_room_name_removed_by_you) + !senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name) + else -> sp.getString(R.string.state_event_room_name_removed, senderDisplayName) + } + } + is OtherState.RoomThirdPartyInvite -> { + if (content.displayName == null) { + Timber.e("RoomThirdPartyInvite undisplayable due to missing name") + return null + } + if (senderIsYou) { + sp.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName) + } else { + sp.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName) + } + } + is OtherState.RoomTopic -> { + val hasRoomTopic = content.topic != null + when { + senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed_by_you, content.topic) + senderIsYou && !hasRoomTopic -> sp.getString(R.string.state_event_room_topic_removed_by_you) + !senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic) + else -> sp.getString(R.string.state_event_room_topic_removed, senderDisplayName) + } + } + is OtherState.Custom -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "Custom event ${content.eventType}" + } + } + OtherState.PolicyRuleRoom -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleRoom" + } + } + OtherState.PolicyRuleServer -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleServer" + } + } + OtherState.PolicyRuleUser -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleUser" + } + } + OtherState.RoomAliases -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomAliases" + } + } + OtherState.RoomCanonicalAlias -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomCanonicalAlias" + } + } + OtherState.RoomGuestAccess -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomGuestAccess" + } + } + OtherState.RoomHistoryVisibility -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomHistoryVisibility" + } + } + OtherState.RoomJoinRules -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomJoinRules" + } + } + OtherState.RoomPinnedEvents -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomPinnedEvents" + } + } + OtherState.RoomPowerLevels -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomPowerLevels" + } + } + OtherState.RoomServerAcl -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomServerAcl" + } + } + OtherState.RoomTombstone -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomTombstone" + } + } + OtherState.SpaceChild -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "SpaceChild" + } + } + OtherState.SpaceParent -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "SpaceParent" + } + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt new file mode 100644 index 0000000000..9f85dd4093 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.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.eventformatter.impl.mode + +enum class RenderingMode { + RoomList, + Timeline, +} diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..298bd3d40b --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,39 @@ + + + "(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/libraries/eventformatter/impl/src/main/res/values-es/translations.xml b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..701f56f41c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,57 @@ + + + "(el avatar también cambió)" + "%1$s cambió su avatar" + "Cambiaste tu avatar" + "%1$s cambió su nombre de %2$s a %3$s" + "Cambiaste tu nombre de %1$s a %2$s" + "%1$s eliminó su nombre (era %2$s)" + "Eliminaste tu nombre (era %1$s)" + "%1$s cambió su nombre a %2$s" + "Cambiaste tu nombre a %1$s" + "%1$s cambió el avatar de la sala" + "Cambiaste el avatar de la sala" + "%1$s eliminó el avatar de la sala" + "Eliminaste el avatar de la sala" + "%1$s expulsó permanentemente a %2$s" + "Expulsaste permanentemente a %1$s" + "%1$s creó la sala" + "Tú creaste la sala" + "%1$s invitó a %2$s" + "%1$s aceptó la invitación" + "Aceptaste la invitación" + "Invitaste a %1$s" + "%1$s te invitó." + "%1$s se unió a la sala" + "Te uniste a la sala" + "%1$s solicitó unirse" + "%1$s permitió que %2$s se uniera" + "%1$s te permitió unirte" + "Solicitaste unirte" + "%1$s rechazó la solicitud de %2$s para unirse" + "Rechazaste la solicitud de %1$s para unirte" + "%1$s rechazó su solicitud para unirte" + "%1$s ya no está interesado en unirse" + "Cancelaste tu solicitud de unirte" + "%1$s salió de la sala" + "Saliste de la sala" + "%1$s cambió el nombre de la sala a: %2$s" + "Cambiaste el nombre de la sala a: %1$s" + "%1$s eliminó el nombre de la sala" + "Eliminaste el nombre de la sala" + "%1$s rechazó la invitación" + "Rechazaste la invitación" + "%1$s echó a %2$s" + "Echaste a %1$s" + "%1$s envió una invitación a %2$s para unirse a la sala" + "Enviaste una invitación a %1$s para unirse a la sala" + "%1$s revocó la invitación a %2$s para unirse a la sala" + "Revocaste la invitación de %1$s para unirse a la sala" + "%1$s cambió el tema a: %2$s" + "Cambiaste el tema a: %1$s" + "%1$s eliminó el tema de la sala" + "Eliminaste el tema de la sala" + "%1$s readmitió a %2$s" + "Readmitiste a %1$s" + "%1$s realizó un cambio desconocido en su membresía" + \ No newline at end of file diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..0380d802f4 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,57 @@ + + + "(anche l\'avatar è stato cambiato)" + "%1$s ha cambiato il proprio avatar" + "Hai cambiato il tuo avatar" + "%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s" + "Hai cambiato il tuo nome visualizzato da %1$s a %2$s" + "%1$s ha rimosso il proprio nome visualizzato (era %2$s)" + "Hai rimosso il tuo nome visualizzato (era %1$s)" + "%1$s ha impostato il proprio nome visualizzato su %2$s" + "Hai impostato il tuo nome visualizzato su %1$s" + "%1$s ha cambiato l\'avatar della stanza" + "Hai cambiato l\'avatar della stanza" + "%1$s ha rimosso l\'avatar della stanza" + "Hai rimosso l\'avatar della stanza" + "%1$s ha rimosso %2$s" + "Hai rimosso %1$s" + "%1$s ha creato la stanza" + "Hai creato la stanza" + "%1$s ha invitato %2$s" + "%1$s ha accettato l\'invito" + "Hai accettato l\'invito" + "Hai invitato %1$s" + "%1$s ti ha invitato" + "%1$s si è unito alla stanza" + "Ti sei unito alla stanza" + "%1$s ha chiesto di unirsi" + "%1$s ha permesso a %2$s di unirsi" + "%1$s ti ha permesso di unirti" + "Hai richiesto di unirti" + "%1$s ha rifiutato la richiesta di unirsi di %2$s" + "Hai rifiutato la richiesta di unirsi di %1$s" + "%1$s ha rifiutato la tua richiesta di unirti" + "%1$s non è più interessato a partecipare" + "Hai annullato la tua richiesta di unirti" + "%1$s ha lasciato la stanza" + "Hai lasciato la stanza" + "%1$s ha cambiato il nome della stanza in: %2$s" + "Hai cambiato il nome della stanza in: %1$s" + "%1$s ha rimosso il nome della stanza" + "Hai rimosso il nome della stanza" + "%1$s ha rifiutato l\'invito" + "Hai rifiutato l\'invito" + "%1$s ha rimosso %2$s" + "Hai rimosso %1$s" + "%1$s ha inviato un invito a %2$s per unirsi alla stanza" + "Hai inviato un invito a %1$s per unirsi alla stanza" + "%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza." + "Hai revocato l\'invito a %1$s a universi alla stanza" + "%1$s ha cambiato l\'oggetto in: %2$s" + "Hai cambiato l\'oggetto in: %1$s" + "%1$s ha rimosso l\'oggetto della stanza" + "Hai rimosso l\'oggetto della stanza" + "%1$s ha sbloccato %2$s" + "Hai sbloccato %1$s" + "%1$s ha apportato una modifica sconosciuta alla propria iscrizione" + \ No newline at end of file diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..2e3abf93d0 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,57 @@ + + + "(s-a schimbat si avatarul)" + "%1$s și-a schimbat avatarul" + "V-ați schimbat avatarul" + "%1$s și-a schimbat numele din %2$s în %3$s" + "V-ați schimbat numele din %1$s în %2$s" + "%1$s și-a sters numele (era %2$s)" + "V-ați sters numele (era %1$s)" + "%1$s și-a schimbat numele %2$s" + "V-ați schimbat numele în %1$s" + "%1$s a schimbat avatarul camerei" + "Ați schimbat avatarul camerei" + "%1$s a șters avatarul camerei" + "Ați șters avatarul camerei" + "%1$s a adăugat o interdicție pentru %2$s" + "Ați adăugat o interdicție pentru %1$s" + "%1$s a creat camera" + "Ați creat camera" + "%1$s l-a invitat pe %2$s" + "%1$s a acceptat invitația" + "Ați acceptat invitația" + "L-ați invitat pe %1$s" + "%1$s v-a invitat" + "%1$s a intrat în cameră" + "Ați intrat în cameră" + "%1$s a solicitat să se alăture camerei" + "%1$s i-a permis lui %2$s să se alăture camerei" + "%1$s v-a permis să vă alăturați camerei" + "Ați solicitat să vă alăturați camerei" + "%1$s a respins solicitarea de alăturare a lui %2$s" + "Ați respins solicitarea de alăturare a lui %1$s" + "%1$s a respins cererea dumneavoastră de alăturare" + "%1$s nu mai este interesat să se alăture camerei" + "Ați anulat cererea de alăturare" + "%1$s a părăsit camera" + "Ați părăsit camera" + "%1$s a schimbat numele camerei în: %2$s" + "Ați schimbat numele camerei în: %1$s" + "%1$s a sters numele camerei" + "Ați șters numele camerei" + "%1$s a respins invitația" + "Ați respins invitația" + "%1$s l-a îndepărtat pe %2$s" + "L-ați îndepărtat pe %1$s" + "%1$s a trimis o invitație către %2$s pentru a se alătura camerei" + "Ați trimis o invitație către %1$s pentru a se alătura camerei" + "%1$s a revocat invitația pentru %2$s de a se alătura camerei" + "Ați revocat invitația pentru %1$s de a se alătura camerei" + "%1$s a schimbat subiectul în: %2$s" + "Ați schimbat subiectul în: %1$s" + "%1$s a șters subiectul camerei" + "Ați șters subiectul camerei" + "%1$s a anulat interdicția pentru %2$s" + "Ați anulat interdicția pentru %1$s" + "%1$s a făcut o modificare necunoscută asupra calității sale de membru" + \ No newline at end of file diff --git a/libraries/eventformatter/impl/src/main/res/values/localazy.xml b/libraries/eventformatter/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..2fd4217cd4 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml @@ -0,0 +1,57 @@ + + + "(avatar was changed too)" + "%1$s changed their avatar" + "You changed your avatar" + "%1$s changed their display name from %2$s to %3$s" + "You changed your display name from %1$s to %2$s" + "%1$s removed their display name (it was %2$s)" + "You removed your display name (it was %1$s)" + "%1$s set their display name to %2$s" + "You set your display name to %1$s" + "%1$s changed the room avatar" + "You changed the room avatar" + "%1$s removed the room avatar" + "You removed the room avatar" + "%1$s banned %2$s" + "You banned %1$s" + "%1$s created the room" + "You created the room" + "%1$s invited %2$s" + "%1$s accepted the invite" + "You accepted the invite" + "You invited %1$s" + "%1$s invited you" + "%1$s joined the room" + "You joined the room" + "%1$s requested to join" + "%1$s allowed %2$s to join" + "%1$s allowed you to join" + "You requested to join" + "%1$s rejected %2$s\'s request to join" + "You rejected %1$s\'s request to join" + "%1$s rejected your request to join" + "%1$s is no longer interested in joining" + "You cancelled your request to join" + "%1$s left the room" + "You left the room" + "%1$s changed the room name to: %2$s" + "You changed the room name to: %1$s" + "%1$s removed the room name" + "You removed the room name" + "%1$s rejected the invitation" + "You rejected the invitation" + "%1$s removed %2$s" + "You removed %1$s" + "%1$s sent an invitation to %2$s to join the room" + "You sent an invitation to %1$s to join the room" + "%1$s revoked the invitation for %2$s to join the room" + "You revoked the invitation for %1$s to join the room" + "%1$s changed the topic to: %2$s" + "You changed the topic to: %1$s" + "%1$s removed the room topic" + "You removed the room topic" + "%1$s unbanned %2$s" + "You unbanned %1$s" + "%1$s made an unknown change to their membership" + \ No newline at end of file diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt similarity index 83% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt rename to libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt index bc9ede5636..ad1ef259b7 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultRoomLastMessageFormatterTests.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.roomlist.impl +package io.element.android.libraries.eventformatter.impl import android.content.Context import androidx.compose.ui.text.AnnotatedString @@ -49,6 +49,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.aProfileChangeMessageContent import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -67,7 +68,14 @@ class DefaultRoomLastMessageFormatterTests { fun setup() { context = RuntimeEnvironment.getApplication() as Context fakeMatrixClient = FakeMatrixClient() - formatter = DefaultRoomLastMessageFormatter(context, fakeMatrixClient) + val stringProvider = AndroidStringProvider(context.resources) + formatter = DefaultRoomLastMessageFormatter( + sp = AndroidStringProvider(context.resources), + matrixClient = fakeMatrixClient, + roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider) + ) } @Test @@ -77,7 +85,7 @@ class DefaultRoomLastMessageFormatterTests { val senderName = "Someone" sequenceOf(false, true).forEach { isDm -> val message = createRoomEvent(false, senderName, RedactedContent) - val result = formatter.processMessageItem(message, isDm) + val result = formatter.format(message, isDm) if (isDm) { Truth.assertThat(result).isEqualTo(expected) } else { @@ -93,7 +101,7 @@ class DefaultRoomLastMessageFormatterTests { val body = "body" val info = ImageInfo(null, null, null, null, null, null, null) val message = createRoomEvent(false, null, StickerContent(body, info, "url")) - val result = formatter.processMessageItem(message, false) + val result = formatter.format(message, false) Truth.assertThat(result).isEqualTo(body) } @@ -104,7 +112,7 @@ class DefaultRoomLastMessageFormatterTests { val senderName = "Someone" sequenceOf(false, true).forEach { isDm -> val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) - val result = formatter.processMessageItem(message, isDm) + val result = formatter.format(message, isDm) if (isDm) { Truth.assertThat(result).isEqualTo(expected) } else { @@ -126,7 +134,7 @@ class DefaultRoomLastMessageFormatterTests { UnknownContent, ).forEach { type -> val message = createRoomEvent(false, senderName, type) - val result = formatter.processMessageItem(message, isDm) + val result = formatter.format(message, isDm) if (isDm) { Truth.assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected) } else { @@ -165,7 +173,7 @@ class DefaultRoomLastMessageFormatterTests { sharedContentMessagesTypes.forEach { type -> val content = createMessageContent(type) val message = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) - val result = formatter.processMessageItem(message, isDmRoom = isDm) + val result = formatter.format(message, isDmRoom = isDm) if (isDm) { resultsInDm.add(type to result) } else { @@ -173,7 +181,7 @@ class DefaultRoomLastMessageFormatterTests { } } val unknownMessage = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = createMessageContent(UnknownMessageType)) - val result = UnknownMessageType to formatter.processMessageItem(unknownMessage, isDmRoom = isDm) + val result = UnknownMessageType to formatter.format(unknownMessage, isDmRoom = isDm) if (isDm) { resultsInDm.add(result) } else { @@ -237,11 +245,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.JOINED) val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youJoinedRoom = formatter.processMessageItem(youJoinedRoomEvent, false) + val youJoinedRoom = formatter.format(youJoinedRoomEvent, false) Truth.assertThat(youJoinedRoom).isEqualTo("You joined the room") val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneJoinedRoom = formatter.processMessageItem(someoneJoinedRoomEvent, false) + val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent, false) Truth.assertThat(someoneJoinedRoom).isEqualTo("${someoneContent.userId} joined the room") } @@ -253,11 +261,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.LEFT) val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youLeftRoom = formatter.processMessageItem(youLeftRoomEvent, false) + val youLeftRoom = formatter.format(youLeftRoomEvent, false) Truth.assertThat(youLeftRoom).isEqualTo("You left the room") val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneLeftRoom = formatter.processMessageItem(someoneLeftRoomEvent, false) + val someoneLeftRoom = formatter.format(someoneLeftRoomEvent, false) Truth.assertThat(someoneLeftRoom).isEqualTo("${someoneContent.userId} left the room") } @@ -271,19 +279,19 @@ class DefaultRoomLastMessageFormatterTests { val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youBanned = formatter.processMessageItem(youBannedEvent, false) + val youBanned = formatter.format(youBannedEvent, false) Truth.assertThat(youBanned).isEqualTo("You banned ${youContent.userId}") val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) - val youKickedBanned = formatter.processMessageItem(youKickBannedEvent, false) + val youKickedBanned = formatter.format(youKickBannedEvent, false) Truth.assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId}") val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneBanned = formatter.processMessageItem(someoneBannedEvent, false) + val someoneBanned = formatter.format(someoneBannedEvent, false) Truth.assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId}") val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) - val someoneKickBanned = formatter.processMessageItem(someoneKickBannedEvent, false) + val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) Truth.assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId}") } @@ -295,11 +303,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youUnbanned = formatter.processMessageItem(youUnbannedEvent, false) + val youUnbanned = formatter.format(youUnbannedEvent, false) Truth.assertThat(youUnbanned).isEqualTo("You unbanned ${youContent.userId}") val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneUnbanned = formatter.processMessageItem(someoneUnbannedEvent, false) + val someoneUnbanned = formatter.format(someoneUnbannedEvent, false) Truth.assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId}") } @@ -311,11 +319,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youKicked = formatter.processMessageItem(youKickedEvent, false) + val youKicked = formatter.format(youKickedEvent, false) Truth.assertThat(youKicked).isEqualTo("You removed ${youContent.userId}") val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneKicked = formatter.processMessageItem(someoneKickedEvent, false) + val someoneKicked = formatter.format(someoneKickedEvent, false) Truth.assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId}") } @@ -327,15 +335,15 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITED) val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) - val youWereInvited = formatter.processMessageItem(youWereInvitedEvent, false) + val youWereInvited = formatter.format(youWereInvitedEvent, false) Truth.assertThat(youWereInvited).isEqualTo("$otherName invited you") val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) - val youInvited = formatter.processMessageItem(youInvitedEvent, false) + val youInvited = formatter.format(youInvitedEvent, false) Truth.assertThat(youInvited).isEqualTo("You invited ${someoneContent.userId}") val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneInvited = formatter.processMessageItem(someoneInvitedEvent, false) + val someoneInvited = formatter.format(someoneInvitedEvent, false) Truth.assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId}") } @@ -347,11 +355,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_ACCEPTED) val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youAcceptedInvite = formatter.processMessageItem(youAcceptedInviteEvent, false) + val youAcceptedInvite = formatter.format(youAcceptedInviteEvent, false) Truth.assertThat(youAcceptedInvite).isEqualTo("You accepted the invite") val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneAcceptedInvite = formatter.processMessageItem(someoneAcceptedInviteEvent, false) + val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent, false) Truth.assertThat(someoneAcceptedInvite).isEqualTo("${someoneContent.userId} accepted the invite") } @@ -363,11 +371,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REJECTED) val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youRejectedInvite = formatter.processMessageItem(youRejectedInviteEvent, false) + val youRejectedInvite = formatter.format(youRejectedInviteEvent, false) Truth.assertThat(youRejectedInvite).isEqualTo("You rejected the invitation") val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneRejectedInvite = formatter.processMessageItem(someoneRejectedInviteEvent, false) + val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent, false) Truth.assertThat(someoneRejectedInvite).isEqualTo("${someoneContent.userId} rejected the invitation") } @@ -378,11 +386,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REVOKED) val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) - val youRevokedInvite = formatter.processMessageItem(youRevokedInviteEvent, false) + val youRevokedInvite = formatter.format(youRevokedInviteEvent, false) Truth.assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for ${someoneContent.userId} to join the room") val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneRevokedInvite = formatter.processMessageItem(someoneRevokedInviteEvent, false) + val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent, false) Truth.assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for ${someoneContent.userId} to join the room") } @@ -394,11 +402,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCKED) val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youKnocked = formatter.processMessageItem(youKnockedEvent, false) + val youKnocked = formatter.format(youKnockedEvent, false) Truth.assertThat(youKnocked).isEqualTo("You requested to join") val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneKnocked = formatter.processMessageItem(someoneKnockedEvent, false) + val someoneKnocked = formatter.format(someoneKnockedEvent, false) Truth.assertThat(someoneKnocked).isEqualTo("${someoneContent.userId} requested to join") } @@ -409,11 +417,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_ACCEPTED) val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) - val youAcceptedKnock = formatter.processMessageItem(youAcceptedKnockEvent, false) + val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false) Truth.assertThat(youAcceptedKnock).isEqualTo("${someoneContent.userId} allowed you to join") val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneAcceptedKnock = formatter.processMessageItem(someoneAcceptedKnockEvent, false) + val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false) Truth.assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed ${someoneContent.userId} to join") } @@ -425,11 +433,11 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_RETRACTED) val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) - val youRetractedKnock = formatter.processMessageItem(youRetractedKnockEvent, false) + val youRetractedKnock = formatter.format(youRetractedKnockEvent, false) Truth.assertThat(youRetractedKnock).isEqualTo("You cancelled your request to join") val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneRetractedKnock = formatter.processMessageItem(someoneRetractedKnockEvent, false) + val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent, false) Truth.assertThat(someoneRetractedKnock).isEqualTo("${someoneContent.userId} is no longer interested in joining") } @@ -441,15 +449,15 @@ class DefaultRoomLastMessageFormatterTests { val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_DENIED) val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) - val youDeniedKnock = formatter.processMessageItem(youDeniedKnockEvent, false) + val youDeniedKnock = formatter.format(youDeniedKnockEvent, false) Truth.assertThat(youDeniedKnock).isEqualTo("You rejected ${someoneContent.userId}'s request to join") val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) - val someoneDeniedKnock = formatter.processMessageItem(someoneDeniedKnockEvent, false) + val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent, false) Truth.assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected ${someoneContent.userId}'s request to join") val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) - val someoneDeniedYourKnock = formatter.processMessageItem(someoneDeniedYourKnockEvent, false) + val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent, false) Truth.assertThat(someoneDeniedYourKnock).isEqualTo("$otherName rejected your request to join") } @@ -461,7 +469,7 @@ class DefaultRoomLastMessageFormatterTests { val results = otherChanges.map { change -> val content = RoomMembershipContent(A_USER_ID, change) val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) - val result = formatter.processMessageItem(event, false) + val result = formatter.format(event, false) change to result } val expected = otherChanges.map { it to null } @@ -480,19 +488,19 @@ class DefaultRoomLastMessageFormatterTests { val removedContent = StateContent("", OtherState.RoomAvatar(null)) val youChangedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedRoomAvatar = formatter.processMessageItem(youChangedRoomAvatarEvent, false) + val youChangedRoomAvatar = formatter.format(youChangedRoomAvatarEvent, false) Truth.assertThat(youChangedRoomAvatar).isEqualTo("You changed the room avatar") val someoneChangedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneChangedRoomAvatar = formatter.processMessageItem(someoneChangedRoomAvatarEvent, false) + val someoneChangedRoomAvatar = formatter.format(someoneChangedRoomAvatarEvent, false) Truth.assertThat(someoneChangedRoomAvatar).isEqualTo("$otherName changed the room avatar") val youRemovedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youRemovedRoomAvatar = formatter.processMessageItem(youRemovedRoomAvatarEvent, false) + val youRemovedRoomAvatar = formatter.format(youRemovedRoomAvatarEvent, false) Truth.assertThat(youRemovedRoomAvatar).isEqualTo("You removed the room avatar") val someoneRemovedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneRemovedRoomAvatar = formatter.processMessageItem(someoneRemovedRoomAvatarEvent, false) + val someoneRemovedRoomAvatar = formatter.format(someoneRemovedRoomAvatarEvent, false) Truth.assertThat(someoneRemovedRoomAvatar).isEqualTo("$otherName removed the room avatar") } @@ -503,11 +511,11 @@ class DefaultRoomLastMessageFormatterTests { val content = StateContent("", OtherState.RoomCreate) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) - val youCreatedRoom = formatter.processMessageItem(youCreatedRoomMessage, false) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) Truth.assertThat(youCreatedRoom).isEqualTo("You created the room") val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) - val someoneCreatedRoom = formatter.processMessageItem(someoneCreatedRoomEvent, false) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) Truth.assertThat(someoneCreatedRoom).isEqualTo("$otherName created the room") } @@ -518,11 +526,11 @@ class DefaultRoomLastMessageFormatterTests { val content = StateContent("", OtherState.RoomEncryption) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) - val youCreatedRoom = formatter.processMessageItem(youCreatedRoomMessage, false) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) Truth.assertThat(youCreatedRoom).isEqualTo("Encryption enabled") val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) - val someoneCreatedRoom = formatter.processMessageItem(someoneCreatedRoomEvent, false) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) Truth.assertThat(someoneCreatedRoom).isEqualTo("Encryption enabled") } @@ -535,19 +543,19 @@ class DefaultRoomLastMessageFormatterTests { val removedContent = StateContent("", OtherState.RoomName(null)) val youChangedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedRoomName = formatter.processMessageItem(youChangedRoomNameEvent, false) + val youChangedRoomName = formatter.format(youChangedRoomNameEvent, false) Truth.assertThat(youChangedRoomName).isEqualTo("You changed the room name to: $newName") val someoneChangedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneChangedRoomName = formatter.processMessageItem(someoneChangedRoomNameEvent, false) + val someoneChangedRoomName = formatter.format(someoneChangedRoomNameEvent, false) Truth.assertThat(someoneChangedRoomName).isEqualTo("$otherName changed the room name to: $newName") val youRemovedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youRemovedRoomName = formatter.processMessageItem(youRemovedRoomNameEvent, false) + val youRemovedRoomName = formatter.format(youRemovedRoomNameEvent, false) Truth.assertThat(youRemovedRoomName).isEqualTo("You removed the room name") val someoneRemovedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneRemovedRoomName = formatter.processMessageItem(someoneRemovedRoomNameEvent, false) + val someoneRemovedRoomName = formatter.format(someoneRemovedRoomNameEvent, false) Truth.assertThat(someoneRemovedRoomName).isEqualTo("$otherName removed the room name") } @@ -560,19 +568,19 @@ class DefaultRoomLastMessageFormatterTests { val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) val youInvitedSomeoneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youInvitedSomeone = formatter.processMessageItem(youInvitedSomeoneEvent, false) + val youInvitedSomeone = formatter.format(youInvitedSomeoneEvent, false) Truth.assertThat(youInvitedSomeone).isEqualTo("You sent an invitation to $inviteeName to join the room") val someoneInvitedSomeoneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneInvitedSomeone = formatter.processMessageItem(someoneInvitedSomeoneEvent, false) + val someoneInvitedSomeone = formatter.format(someoneInvitedSomeoneEvent, false) Truth.assertThat(someoneInvitedSomeone).isEqualTo("$otherName sent an invitation to $inviteeName to join the room") val youInvitedNoOneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youInvitedNoOne = formatter.processMessageItem(youInvitedNoOneEvent, false) + val youInvitedNoOne = formatter.format(youInvitedNoOneEvent, false) Truth.assertThat(youInvitedNoOne).isNull() val someoneInvitedNoOneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneInvitedNoOne = formatter.processMessageItem(someoneInvitedNoOneEvent, false) + val someoneInvitedNoOne = formatter.format(someoneInvitedNoOneEvent, false) Truth.assertThat(someoneInvitedNoOne).isNull() } @@ -585,19 +593,19 @@ class DefaultRoomLastMessageFormatterTests { val removedContent = StateContent("", OtherState.RoomTopic(null)) val youChangedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedRoomTopic = formatter.processMessageItem(youChangedRoomTopicEvent, false) + val youChangedRoomTopic = formatter.format(youChangedRoomTopicEvent, false) Truth.assertThat(youChangedRoomTopic).isEqualTo("You changed the topic to: $roomTopic") val someoneChangedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneChangedRoomTopic = formatter.processMessageItem(someoneChangedRoomTopicEvent, false) + val someoneChangedRoomTopic = formatter.format(someoneChangedRoomTopicEvent, false) Truth.assertThat(someoneChangedRoomTopic).isEqualTo("$otherName changed the topic to: $roomTopic") val youRemovedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youRemovedRoomTopic = formatter.processMessageItem(youRemovedRoomTopicEvent, false) + val youRemovedRoomTopic = formatter.format(youRemovedRoomTopicEvent, false) Truth.assertThat(youRemovedRoomTopic).isEqualTo("You removed the room topic") val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneRemovedRoomTopic = formatter.processMessageItem(someoneRemovedRoomTopicEvent, false) + val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent, false) Truth.assertThat(someoneRemovedRoomTopic).isEqualTo("$otherName removed the room topic") } @@ -613,7 +621,7 @@ class DefaultRoomLastMessageFormatterTests { val results = otherStates.map { state -> val content = StateContent("", state) val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) - val result = formatter.processMessageItem(event, false) + val result = formatter.format(event, false) state to result } val expected = otherStates.map { it to null } @@ -635,35 +643,35 @@ class DefaultRoomLastMessageFormatterTests { val sameContent = aProfileChangeMessageContent(avatarUrl = "same_avatar_url", prevAvatarUrl = "same_avatar_url") val youChangedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedAvatar = formatter.processMessageItem(youChangedAvatarEvent, false) + val youChangedAvatar = formatter.format(youChangedAvatarEvent, false) Truth.assertThat(youChangedAvatar).isEqualTo("You changed your avatar") val someoneChangeAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneChangeAvatar = formatter.processMessageItem(someoneChangeAvatarEvent, false) + val someoneChangeAvatar = formatter.format(someoneChangeAvatarEvent, false) Truth.assertThat(someoneChangeAvatar).isEqualTo("$otherName changed their avatar") val youSetAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) - val youSetAvatar = formatter.processMessageItem(youSetAvatarEvent, false) + val youSetAvatar = formatter.format(youSetAvatarEvent, false) Truth.assertThat(youSetAvatar).isEqualTo("You changed your avatar") val someoneSetAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) - val someoneSetAvatar = formatter.processMessageItem(someoneSetAvatarEvent, false) + val someoneSetAvatar = formatter.format(someoneSetAvatarEvent, false) Truth.assertThat(someoneSetAvatar).isEqualTo("$otherName changed their avatar") val youRemovedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youRemovedAvatar = formatter.processMessageItem(youRemovedAvatarEvent, false) + val youRemovedAvatar = formatter.format(youRemovedAvatarEvent, false) Truth.assertThat(youRemovedAvatar).isEqualTo("You changed your avatar") val someoneRemovedAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneRemovedAvatar = formatter.processMessageItem(someoneRemovedAvatarEvent, false) + val someoneRemovedAvatar = formatter.format(someoneRemovedAvatarEvent, false) Truth.assertThat(someoneRemovedAvatar).isEqualTo("$otherName changed their avatar") val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) - val unchangedResult = formatter.processMessageItem(unchangedEvent, false) + val unchangedResult = formatter.format(unchangedEvent, false) Truth.assertThat(unchangedResult).isNull() val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) - val invalidResult = formatter.processMessageItem(invalidEvent, false) + val invalidResult = formatter.format(invalidEvent, false) Truth.assertThat(invalidResult).isNull() } @@ -680,35 +688,35 @@ class DefaultRoomLastMessageFormatterTests { val invalidContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = null) val youChangedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedDisplayName = formatter.processMessageItem(youChangedDisplayNameEvent, false) + val youChangedDisplayName = formatter.format(youChangedDisplayNameEvent, false) Truth.assertThat(youChangedDisplayName).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName") val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) - val someoneChangedDisplayName = formatter.processMessageItem(someoneChangedDisplayNameEvent, false) + val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent, false) Truth.assertThat(someoneChangedDisplayName).isEqualTo("$otherName changed their display name from $oldDisplayName to $newDisplayName") val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) - val youSetDisplayName = formatter.processMessageItem(youSetDisplayNameEvent, false) + val youSetDisplayName = formatter.format(youSetDisplayNameEvent, false) Truth.assertThat(youSetDisplayName).isEqualTo("You set your display name to $newDisplayName") val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) - val someoneSetDisplayName = formatter.processMessageItem(someoneSetDisplayNameEvent, false) + val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent, false) Truth.assertThat(someoneSetDisplayName).isEqualTo("$otherName set their display name to $newDisplayName") val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) - val youRemovedDisplayName = formatter.processMessageItem(youRemovedDisplayNameEvent, false) + val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent, false) Truth.assertThat(youRemovedDisplayName).isEqualTo("You removed your display name (it was $oldDisplayName)") val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) - val someoneRemovedDisplayName = formatter.processMessageItem(someoneRemovedDisplayNameEvent, false) + val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent, false) Truth.assertThat(someoneRemovedDisplayName).isEqualTo("$otherName removed their display name (it was $oldDisplayName)") val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) - val unchangedResult = formatter.processMessageItem(unchangedEvent, false) + val unchangedResult = formatter.format(unchangedEvent, false) Truth.assertThat(unchangedResult).isNull() val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) - val invalidResult = formatter.processMessageItem(invalidEvent, false) + val invalidResult = formatter.format(invalidEvent, false) Truth.assertThat(invalidResult).isNull() } @@ -737,15 +745,15 @@ class DefaultRoomLastMessageFormatterTests { ) val youChangedBothEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) - val youChangedBoth = formatter.processMessageItem(youChangedBothEvent, false) + val youChangedBoth = formatter.format(youChangedBothEvent, false) Truth.assertThat(youChangedBoth).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName\n(avatar was changed too)") val invalidContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = invalidContent) - val invalidMessage = formatter.processMessageItem(invalidContentEvent, false) + val invalidMessage = formatter.format(invalidContentEvent, false) Truth.assertThat(invalidMessage).isNull() val sameContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = sameContent) - val sameMessage = formatter.processMessageItem(sameContentEvent, false) + val sameMessage = formatter.format(sameContentEvent, false) Truth.assertThat(sameMessage).isNull() } diff --git a/libraries/eventformatter/test/build.gradle.kts b/libraries/eventformatter/test/build.gradle.kts new file mode 100644 index 0000000000..8250c57247 --- /dev/null +++ b/libraries/eventformatter/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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-library") +} + +android { + namespace = "io.element.android.libraries.eventformatter.test" +} + +dependencies { + implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt similarity index 67% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt rename to libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt index e0763748bf..cd723a27af 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeRoomLastMessageFormatter.kt +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt @@ -14,18 +14,20 @@ * limitations under the License. */ -package io.element.android.features.roomlist.impl +package io.element.android.libraries.eventformatter.test +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem class FakeRoomLastMessageFormatter : RoomLastMessageFormatter { - private var processMessageItemResult: CharSequence? = null - override fun processMessageItem(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { - return processMessageItemResult + private var result: CharSequence? = null + + override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + return result } - fun givenRoomSummaryResult(result: CharSequence?) { - processMessageItemResult = result + fun givenFormatResult(result: CharSequence?) { + this.result = result } } 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 181c176802..bac98dd852 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -44,6 +46,7 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 09afe0a861..9b6be804c9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -105,9 +105,6 @@ class DefaultPushHandler @Inject constructor( Timber.tag(loggerTag.value).d("## handleInternal()") } - pushData.roomId ?: return - pushData.eventId ?: return - val clientSecret = pushData.clientSecret val userId = if (clientSecret == null) { // Should not happen. In this case, restore default session diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml index 8bf4b581dd..d1ce970c3c 100644 --- a/libraries/push/impl/src/main/res/values-de/translations.xml +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -8,11 +8,16 @@ "%1$s: %2$s" "%1$s: %2$s %3$s" "%1$s und %2$s" + "%1$s in %2$s" "%1$s in %2$s und %3$s" "%1$s: %2$d Nachricht" "%1$s: %2$d Nachrichten" + + "%d Mitteilung" + "%d Mitteilungen" + "%d Einladung" "%d Einladungen" diff --git a/libraries/statemachine/src/main/kotlin/io/element/android/libraries/statemachine/StateMachine.kt b/libraries/statemachine/src/main/kotlin/io/element/android/libraries/statemachine/StateMachine.kt index ccb98e0563..dd8ea7114d 100644 --- a/libraries/statemachine/src/main/kotlin/io/element/android/libraries/statemachine/StateMachine.kt +++ b/libraries/statemachine/src/main/kotlin/io/element/android/libraries/statemachine/StateMachine.kt @@ -60,6 +60,7 @@ class StateMachine( currentStateConfig?.onEnter?.invoke(nextState) } + @Suppress("UNCHECKED_CAST") private fun findMatchingRoute(event: E): StateMachineRoute? { val routesForEvent = routes.filter { it.eventType.isInstance(event) } diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt index 2fe81b44ac..e5ab34ebfe 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.textcomposer.databinding.ComposerRichTextLay import io.element.android.libraries.textcomposer.databinding.ViewRichTextMenuButtonBinding import io.element.android.libraries.textcomposer.tools.setTextIfDifferent import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.inputhandlers.models.InlineFormat +import io.element.android.wysiwyg.view.models.InlineFormat import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction import io.element.android.libraries.resources.R as ElementR 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 4cd9d61ac9..2fb21016e5 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -26,6 +26,7 @@ "Raum verlassen" "Weiter" "Nein" + "Nicht jetzt" "OK" "Schnellantwort" "Zitieren" @@ -41,10 +42,11 @@ "Teilen" "Link teilen" "Überspringen" + "Chat starten" "Foto aufnehmen" "Ja" "Über" - "Analytik" + "Analyse" "Audio" "Blasen" "Entschlüsselungsfehler" @@ -61,8 +63,12 @@ "Offline" "Passwort" "Reaktionen" + "Fehler melden" + "Suchergebnisse" "Sicherheit" + "Server wird nicht unterstützt" "Einstellungen" + "Chat wird gestartet…" "Sticker" "Erfolg" "Vorschläge" @@ -84,6 +90,7 @@ "Reisen & Orte" "Symbole" "Fehler beim Laden der Nachrichten" + "Einige Nachrichten wurden nicht gesendet" "Entschuldigung, ein Fehler ist aufgetreten." "%1$s Android" @@ -93,6 +100,9 @@ "Grund für die Meldung dieses Inhalts" "Dies ist der Anfang von %1$s." "Neu" + "Wir erfassen und analysieren ""keine"" Account-Daten" + "Sie können die Analyse jederzeit in den Einstellungen deaktivieren" + "Wir geben ""keine"" Informationen an Dritte weiter" "Teile Analyse-Daten" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." "Erkennungsschwelle" 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 78a386f650..b1e73503fa 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -116,10 +116,6 @@ "%1$d miembro" "%1$d miembros" - - "%1$d cambio en la sala" - "%1$d cambios en la sala" - "Agitar con fuerza para informar de un error" "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?" "Este mensaje se notificará al administrador de su homeserver. No podrán leer ningún mensaje cifrado." 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 20f255a21c..c8b16a7ea5 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -116,10 +116,6 @@ "%1$d membro" "%1$d membri" - - "%1$d modifica alla stanza" - "%1$d modifiche alla stanza" - "Scuoti per segnalare un problema" "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?" "Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi criptati." 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 d7b052badc..efcd69b3fc 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -125,11 +125,6 @@ "%1$d membri" "%1$d membri" - - "%1$d schimbare a camerii" - "%1$d schimbări ale camerei" - "%1$d schimbări ale camerei" - "Rageshake pentru a raporta erori" "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat." diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index ca2bcd1d28..163d65f19f 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -122,7 +122,7 @@ "Failed loading messages" "Some messages have not been sent" "Sorry, an error occurred" - "🔐️ Join me on %1$s" + "🔐️ Join me on %1$s" "Hey, talk to me on %1$s: %2$s" "Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you." "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." @@ -132,10 +132,6 @@ "%1$d member" "%1$d members" - - "%1$d room change" - "%1$d room changes" - "Rageshake to report bug" "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." @@ -167,4 +163,4 @@ "You can read all our terms %1$s." "here" "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 c433ed2e07..c5fc4dfd58 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -19,6 +19,7 @@ package extension import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.kotlin.dsl.DependencyHandlerScope import org.gradle.kotlin.dsl.project +import org.gradle.api.logging.Logger import java.io.File private fun DependencyHandlerScope.implementation(dependency: Any) = dependencies.add("implementation", dependency) @@ -53,16 +54,21 @@ fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) { implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") } -private fun DependencyHandlerScope.addImplementationProjects(directory: File, path: String, nameFilter: String) { - directory.listFiles().orEmpty().forEach { file -> +private fun DependencyHandlerScope.addImplementationProjects( + directory: File, + path: String, + nameFilter: String, + logger: Logger, +) { + directory.listFiles().orEmpty().also { it.sort() }.forEach { file -> if (file.isDirectory) { val newPath = "$path:${file.name}" val buildFile = File(file, "build.gradle.kts") if (buildFile.exists() && file.name == nameFilter) { implementation(project(newPath)) - println("Added implementation(project($newPath))") + logger.lifecycle("Added implementation(project($newPath))") } else { - addImplementationProjects(file, newPath, nameFilter) + addImplementationProjects(file, newPath, nameFilter, logger) } } } @@ -74,6 +80,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:matrixui")) implementation(project(":libraries:network")) implementation(project(":libraries:core")) + implementation(project(":libraries:eventformatter:impl")) implementation(project(":libraries:permissions:impl")) implementation(project(":libraries:push:impl")) implementation(project(":libraries:push:impl")) @@ -100,11 +107,12 @@ fun DependencyHandlerScope.allServicesImpl() { implementation(project(":services:toolbox:impl")) } -fun DependencyHandlerScope.allFeaturesApi(rootDir: File) { +fun DependencyHandlerScope.allFeaturesApi(rootDir: File, logger: Logger) { val featuresDir = File(rootDir, "features") - addImplementationProjects(featuresDir, ":features", "api") + addImplementationProjects(featuresDir, ":features", "api", logger) } -fun DependencyHandlerScope.allFeaturesImpl(rootDir: File) { + +fun DependencyHandlerScope.allFeaturesImpl(rootDir: File, logger: Logger) { val featuresDir = File(rootDir, "features") - addImplementationProjects(featuresDir, ":features", "impl") + addImplementationProjects(featuresDir, ":features", "impl", logger) } diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 83e358ad48..6ac7599d4a 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -55,10 +55,13 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.core) implementation(projects.libraries.dateformatter.impl) + implementation(projects.libraries.eventformatter.impl) implementation(projects.features.invitelist.impl) implementation(projects.features.roomlist.impl) + implementation(projects.features.leaveroom.impl) implementation(projects.features.login.impl) implementation(projects.features.networkmonitor.impl) + implementation(projects.services.toolbox.impl) implementation(libs.coroutines.core) - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") + coreLibraryDesugaring(libs.android.desugar) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index aab7d5ca5f..d6cc2e2e31 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -21,9 +21,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore +import io.element.android.features.leaveroom.impl.LeaveRoomPresenterImpl import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl import io.element.android.features.roomlist.impl.DefaultInviteStateDataSource -import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter import io.element.android.features.roomlist.impl.RoomListPresenter import io.element.android.features.roomlist.impl.RoomListView import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -31,8 +31,14 @@ import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.eventformatter.impl.DefaultRoomLastMessageFormatter +import io.element.android.libraries.eventformatter.impl.ProfileChangeContentFormatter +import io.element.android.libraries.eventformatter.impl.RoomMembershipContentFormatter +import io.element.android.libraries.eventformatter.impl.StateContentFormatter import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -50,14 +56,22 @@ class RoomListScreen( private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() + private val stringProvider = AndroidStringProvider(context.resources) private val presenter = RoomListPresenter( client = matrixClient, lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), - roomLastMessageFormatter = DefaultRoomLastMessageFormatter(context, matrixClient), + roomLastMessageFormatter = DefaultRoomLastMessageFormatter( + sp = stringProvider, + matrixClient = matrixClient, + roomMembershipContentFormatter = RoomMembershipContentFormatter(matrixClient, stringProvider), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider), + ), sessionVerificationService = sessionVerificationService, networkMonitor = NetworkMonitorImpl(context), snackbarDispatcher = SnackbarDispatcher(), - inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers) + inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), + leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver() ,coroutineDispatchers) ) @Composable @@ -82,8 +96,13 @@ class RoomListScreen( val state = presenter.present() RoomListView( state = state, - modifier = modifier, onRoomClicked = ::onRoomClicked, + onSettingsClicked = {}, + onVerifyClicked = {}, + onCreateRoomClicked = {}, + onInvitesClicked = {}, + onRoomSettingsClicked = {}, + modifier = modifier, ) DisposableEffect(Unit) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 1173288adb..9b2d507b7e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,13 +56,13 @@ include(":anvilcodegen") include(":samples:minimal") fun includeProjects(directory: File, path: String, maxDepth: Int = 1) { - directory.listFiles().orEmpty().forEach { file -> + directory.listFiles().orEmpty().also { it.sort() }.forEach { file -> if (file.isDirectory) { val newPath = "$path:${file.name}" val buildFile = File(file, "build.gradle.kts") if (buildFile.exists()) { include(newPath) - println("Included project: $newPath") + logger.lifecycle("Included project: $newPath") } else if (maxDepth > 0) { includeProjects(file, newPath, maxDepth - 1) } 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 index e155eabd73..a19dd629ae 100644 --- 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 @@ -17,27 +17,33 @@ package io.element.android.tests.testutils import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher +/** + * Create a [CoroutineDispatchers] instance for testing. + * + * @param testScheduler The [TestCoroutineScheduler] to use. If using [runTest] use the one provided by its [TestScope]. + * If null the [TestDispatcher] logic will select one or create a new one. + * @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers. + * If false, use [StandardTestDispatcher] for all dispatchers. + */ fun testCoroutineDispatchers( testScheduler: TestCoroutineScheduler? = null, -) = CoroutineDispatchers( - io = UnconfinedTestDispatcher(testScheduler), - computation = UnconfinedTestDispatcher(testScheduler), - main = UnconfinedTestDispatcher(testScheduler), - diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler), -) + useUnconfinedTestDispatcher: Boolean = true, +): CoroutineDispatchers = when (useUnconfinedTestDispatcher) { + false -> CoroutineDispatchers( + io = StandardTestDispatcher(testScheduler), + computation = StandardTestDispatcher(testScheduler), + main = StandardTestDispatcher(testScheduler), + diffUpdateDispatcher = StandardTestDispatcher(testScheduler), + ) -fun testCoroutineDispatchers( - io: TestDispatcher = UnconfinedTestDispatcher(), - computation: TestDispatcher = UnconfinedTestDispatcher(), - main: TestDispatcher = UnconfinedTestDispatcher(), - diffUpdateDispatcher: TestDispatcher = UnconfinedTestDispatcher(), -) = CoroutineDispatchers( - io = io, - computation = computation, - main = main, - diffUpdateDispatcher = diffUpdateDispatcher, -) + true -> CoroutineDispatchers( + io = UnconfinedTestDispatcher(testScheduler), + computation = UnconfinedTestDispatcher(testScheduler), + main = UnconfinedTestDispatcher(testScheduler), + diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler), + ) +} diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts index e120e55087..307cbcdd5d 100644 --- a/tests/uitests/build.gradle.kts +++ b/tests/uitests/build.gradle.kts @@ -37,5 +37,5 @@ dependencies { implementation(libs.showkase) allLibrariesImpl() - allFeaturesImpl(rootDir) + allFeaturesImpl(rootDir, logger) } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.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.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..83e91d47af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f20d59b9bb1ea540e7a1f0c1aeefd1439340625429af672bd6cd00f0c3fdc7f6 +size 5150 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4738df50ef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03ceca5d30354b7736be1bc079053db5ff82d2ac330161c5e07a5d801a24220a +size 20341 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..31fb5c37a5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfe27aff64c8ec1c71b9c41ebd7cef9a59df69275bbb3637cf4a7e0c88bdcb0e +size 32997 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8e9a36e71 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2148b3ffec6f6e953617b035702299f4bb1b5f7f56eacce907f333076da75bd9 +size 36701 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fa09501b15 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7de61d642b9b98c9f0bb8b015b5c57d9645a1552ebed6f0cd215c051c47f0896 +size 10593 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dbf334a4a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59529b30fa2cfef483b365ab888e9327b2dcbae4e40a571642cb534d80379ce2 +size 18439 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a715455c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47e2b24a91c59154221e16b1c9e80689300c077d3f19b8dba4d539754c630e70 +size 5144 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ecbf7acd67 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e58c6e0ea9e0df37794d4ac2f8e40ff7995454626618fc0183b5900d62dcfec +size 20456 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..424bc9edae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71ab2aab2b1aba8baa95c2bd844d5b01410bb3d6004f561038919cf6a7a6b6f5 +size 32991 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..af9816dd4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ceb2bbf886fdd28c8ed2aa755241725de106ae998825e10d3c6608f100987cb1 +size 36673 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34e8908056 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:165597506f493c5adedc840fc22ac0218517bb8ae1c4a7f1fabe43665d0f154f +size 10740 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_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.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..104e886089 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:470b5e47f6b21819aa751736f24f7afd88a5ce3ac911f9d0b6bfbdf94e2f918b +size 18597 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ea5deaa132 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a94a376c6b7f7db5e901e23cd9ba4858bdd47fa330161b4d30daa977efda048 +size 3078 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a076d976ed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:977ad7144c5d2cb5d6eec5bcfbedf81bc39a83e9fcd0243aab06252d9090640b +size 3094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_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.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fc37edc2ef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1846b6a8267752a502ded5cfba052569c929fbb7b53748885b6aba43638b745 +size 54262 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_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.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f638023723 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c90a40ad84d4ee108ddbd94fb7dfb9de0203f792c51409565b146f26fcf3763 +size 53225 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5fb8afab7b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d7d5bc725daafd716308b8f5415a8357608941992c1f1133300aaccb81969ac +size 1582 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a8662beb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dae0c56765a6004b43f904e652b1605294eb688c7b3d72af4385f2c58a8234eb +size 1651 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_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.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e24e462642 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f535800616d0b9a03fd00088a823bc58b359c0c20c311816de824279308fee3 +size 40267 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_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.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..75c9b207a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5bcc73a77a86083a2229b174122ec76d0fdf07973f838c26862e9ece4990311 +size 39133 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_RoomListModalBottomSheetContentDarkPreview_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_RoomListModalBottomSheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6ac7bc4bf1 --- /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_RoomListModalBottomSheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:514d015e7edd0c48746439c9e6a929cd24f92c36302d0d746ca71f0482283439 +size 23123 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_RoomListModalBottomSheetContentLightPreview_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_RoomListModalBottomSheetContentLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3da8979b19 --- /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_RoomListModalBottomSheetContentLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f9c2e01fd3d0ef827808265308555542e5d24055714c7a632e8baf1baffc8e +size 21974 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_8,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_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4b5f95b8d --- /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_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +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_RoomListViewLightPreview_0_null_8,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_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2aa0933d88 --- /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_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +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.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_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_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fca921c50b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27 +size 4464 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_BottomSheets_ModalBottomSheetLightPreview_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_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 5219187f75..ef7110a160 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -56,6 +56,12 @@ "error_no_compatible_app_found" ] }, + { + "name": ":libraries:eventformatter:impl", + "includeRegex": [ + "state_event_.*" + ] + }, { "name": ":libraries:push:impl", "includeRegex": [ @@ -73,7 +79,6 @@ { "name": ":features:roomlist:impl", "includeRegex": [ - "state_event_.*", "screen_roomlist_.*", "session_verification_banner_.*" ] @@ -90,7 +95,8 @@ "name": ":features:messages:impl", "includeRegex": [ "screen_room_.*", - "screen_dm_details_.*" + "screen_dm_details_.*", + "room_timeline_state_changes" ], "excludeRegex": [ "screen_room_details_.*",