Favorite : rework and add tests

This commit is contained in:
ganfra 2024-02-02 14:54:28 +01:00
parent b15597509d
commit d9017a098c
21 changed files with 454 additions and 144 deletions

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.roomactions.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomactions.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
/**
* Set the favorite status of a room.
* This will update the notable tags of the room.
*/
interface SetRoomIsFavoriteAction {
sealed interface Result {
data object Success : Result
data object RoomNotFound : Result
data class Exception(val inner: java.lang.Exception) : Result
}
/**
* Set the favorite status of a room by its id, it'll try to load the room from the session.
*/
suspend operator fun invoke(roomId: RoomId, isFavorite: Boolean): Result
/**
* Set the favorite status of a room using the provided instance.
*/
suspend operator fun invoke(room: MatrixRoom, isFavorite: Boolean): Result
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.roomactions.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.roomactions.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.roomactions.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.libraries.di.SessionScope
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 kotlinx.coroutines.flow.first
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@ContributesBinding(SessionScope::class)
class DefaultSetRoomIsFavoriteAction @Inject constructor(private val client: MatrixClient) : SetRoomIsFavoriteAction {
override suspend operator fun invoke(roomId: RoomId, isFavorite: Boolean): SetRoomIsFavoriteAction.Result {
return client.getRoom(roomId).use { room ->
room?.setIsFavorite(isFavorite) ?: SetRoomIsFavoriteAction.Result.RoomNotFound
}
}
override suspend fun invoke(room: MatrixRoom, isFavorite: Boolean): SetRoomIsFavoriteAction.Result {
return room.setIsFavorite(isFavorite)
}
private suspend fun MatrixRoom.setIsFavorite(isFavorite: Boolean): SetRoomIsFavoriteAction.Result {
val notableTags = notableTagsFlow.first().copy(isFavorite = isFavorite)
return updateNotableTags(notableTags).fold(
onSuccess = {
SetRoomIsFavoriteAction.Result.Success
},
onFailure = { throwable ->
if (throwable is Exception && throwable !is CancellationException) {
SetRoomIsFavoriteAction.Result.Exception(throwable)
} else {
throw throwable
}
}
)
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomactions.impl
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSetRoomIsFavoriteActionTests {
private val room = FakeMatrixRoom()
@Test
fun `given a room id and a client without rooms, when action is invoked, then it returns Result_RoomNotFound`() = runTest {
val action = DefaultSetRoomIsFavoriteAction(FakeMatrixClient())
val result = action(room.roomId, true)
assert(result is SetRoomIsFavoriteAction.Result.RoomNotFound)
}
@Test
fun `given a room, when action is invoked, then it returns Result_Success`() = runTest {
val action = DefaultSetRoomIsFavoriteAction(FakeMatrixClient())
val result = action(room, true)
assert(result is SetRoomIsFavoriteAction.Result.Success)
}
@Test
fun `given a room id and a client with a room, when action is invoked, then it returns Result_Success`() = runTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(room.roomId, room)
}
val action = DefaultSetRoomIsFavoriteAction(client)
val result = action(room.roomId, true)
assert(result is SetRoomIsFavoriteAction.Result.Success)
}
@Test
fun `given a room, when action is invoked and fail, then it returns Result_Exception`() = runTest {
val action = DefaultSetRoomIsFavoriteAction(FakeMatrixClient())
room.givenUpdateNotableTagsResult(Result.failure(Exception()))
val result = action(room, true)
assert(result is SetRoomIsFavoriteAction.Result.Exception)
}
}

View file

@ -0,0 +1,28 @@
/*
* 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-library")
}
android {
namespace = "io.element.android.features.roomactions.test"
}
dependencies {
implementation(projects.features.roomactions.api)
implementation(projects.libraries.matrix.api)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.roomactions.test
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
class FakeSetRoomIsFavoriteAction : SetRoomIsFavoriteAction {
private var counter = 0
fun assertCalled(number: Int) {
assert(counter == number) { "Expected $number calls, got $counter" }
}
override suspend fun invoke(roomId: RoomId, isFavorite: Boolean): SetRoomIsFavoriteAction.Result {
counter++
return SetRoomIsFavoriteAction.Result.Success
}
override suspend fun invoke(room: MatrixRoom, isFavorite: Boolean): SetRoomIsFavoriteAction.Result {
counter++
return SetRoomIsFavoriteAction.Result.Success
}
}

View file

@ -54,6 +54,7 @@ dependencies {
implementation(projects.features.createroom.api)
implementation(projects.services.analytics.api)
implementation(projects.features.poll.api)
implementation(projects.features.roomactions.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -70,6 +71,7 @@ dependencies {
testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.roomactions.test)
ksp(libs.showkase.processor)
}

View file

@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.Lifecycle
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -44,7 +45,6 @@ import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@ -61,6 +61,7 @@ class RoomDetailsPresenter @Inject constructor(
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: LeaveRoomPresenter,
private val dispatchers: CoroutineDispatchers,
private val setRoomIsFavorite: SetRoomIsFavoriteAction,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
@ -126,9 +127,8 @@ class RoomDetailsPresenter @Inject constructor(
}
}
is RoomDetailsEvent.SetIsFavorite -> {
scope.launch(dispatchers.io) {
val tags = RoomNotableTags(isFavorite = event.isFavorite)
room.updateNotableTags(tags)
scope.launch {
setRoomIsFavorite(room, event.isFavorite)
}
}
}

View file

@ -19,14 +19,18 @@ package io.element.android.features.roomdetails
import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.features.roomactions.test.FakeSetRoomIsFavoriteAction
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsState
import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember
@ -40,6 +44,7 @@ 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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
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
@ -71,10 +76,11 @@ class RoomDetailsPresenterTests {
}
private fun TestScope.createRoomDetailsPresenter(
room: MatrixRoom,
room: MatrixRoom = aMatrixRoom(),
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
setRoomIsFavoriteAction: SetRoomIsFavoriteAction = FakeSetRoomIsFavoriteAction(),
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@ -86,25 +92,30 @@ class RoomDetailsPresenterTests {
mapOf(FeatureFlags.NotificationSettings.key to true)
)
return RoomDetailsPresenter(
matrixClient,
room,
featureFlagService,
matrixClient.notificationSettingsService(),
roomMemberDetailsPresenterFactory,
leaveRoomPresenter,
dispatchers
client = matrixClient,
room = room,
featureFlagService = featureFlagService,
notificationSettingsService = matrixClient.notificationSettingsService(),
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter = leaveRoomPresenter,
dispatchers = dispatchers,
setRoomIsFavorite = setRoomIsFavoriteAction,
)
}
private suspend fun RoomDetailsPresenter.test(validate: suspend TurbineTestContext<RoomDetailsState>.() -> Unit) {
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
present()
}
}.test(validate = validate)
}
@Test
fun `present - initial state is created from room if roomInfo is null`() = runTest {
val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
assertThat(initialState.roomName).isEqualTo(room.name)
@ -124,11 +135,7 @@ class RoomDetailsPresenterTests {
givenRoomInfo(roomInfo)
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
skipItems(1)
val updatedState = awaitItem()
assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
@ -143,11 +150,7 @@ class RoomDetailsPresenterTests {
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomName).isEqualTo(room.displayName)
@ -167,11 +170,7 @@ class RoomDetailsPresenterTests {
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
@ -185,11 +184,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(true))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Initially false
assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true
@ -205,11 +200,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
@ -222,11 +213,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.failure(Throwable("Whoops")))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
@ -242,11 +229,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
@ -273,11 +256,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes, but editing is still disallowed because it's a DM
@ -305,11 +284,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
skipItems(1)
// There's no topic, so we hide the entire UI for DMs
@ -328,11 +303,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
@ -351,11 +322,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse()
@ -371,11 +338,7 @@ class RoomDetailsPresenterTests {
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
@ -392,11 +355,7 @@ class RoomDetailsPresenterTests {
}
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
// Ignore the initial state
skipItems(1)
@ -416,11 +375,7 @@ class RoomDetailsPresenterTests {
leaveRoomPresenter = leaveRoomPresenter,
dispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId))
@ -439,11 +394,7 @@ class RoomDetailsPresenterTests {
leaveRoomPresenter = leaveRoomPresenter,
notificationSettingsService = notificationSettingsService,
)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@ -458,11 +409,7 @@ class RoomDetailsPresenterTests {
val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
@ -480,11 +427,7 @@ class RoomDetailsPresenterTests {
)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
@ -493,6 +436,35 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val setRoomIsFavoriteAction = FakeSetRoomIsFavoriteAction()
val presenter = createRoomDetailsPresenter(setRoomIsFavoriteAction = setRoomIsFavoriteAction)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEvent.SetIsFavorite(true))
setRoomIsFavoriteAction.assertCalled(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - changes in notable tags updates is favorite flag`() = runTest {
val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room = room)
presenter.test {
room.updateNotableTags(RoomNotableTags(true))
consumeItemsUntilPredicate { it.isFavorite }.last().let { state ->
assertThat(state.isFavorite).isTrue()
}
room.updateNotableTags(RoomNotableTags(false))
consumeItemsUntilPredicate { !it.isFavorite }.last().let { state ->
assertThat(state.isFavorite).isFalse()
}
cancelAndIgnoreRemainingEvents()
}
}
}
fun aMatrixRoom(

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
implementation(projects.services.analytics.api)
implementation(projects.features.roomactions.api)
api(projects.features.roomlist.api)
ksp(libs.showkase.processor)
@ -75,4 +76,5 @@ dependencies {
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.test)
testImplementation(projects.features.roomactions.test)
}

View file

@ -60,7 +60,7 @@ fun RoomListContextMenu(
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
},
onFavoriteChanged = { isFavorite ->
eventSink(RoomListEvents.MarkRoomAsFavorite(contextMenu.roomId, isFavorite))
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
)
}

View file

@ -28,5 +28,5 @@ sealed interface RoomListEvents {
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data object HideContextMenu : RoomListEvents
data class LeaveRoom(val roomId: RoomId) : RoomListEvents
data class MarkRoomAsFavorite(val roomId: RoomId, val isFavorite: Boolean) : RoomListEvents
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : RoomListEvents
}

View file

@ -31,6 +31,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.libraries.architecture.AsyncData
@ -45,17 +46,14 @@ import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val EXTENDED_RANGE_SIZE = 40
@ -71,6 +69,7 @@ class RoomListPresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService,
private val setRoomIsFavorite: SetRoomIsFavoriteAction,
) : Presenter<RoomListState> {
@Composable
override fun present(): RoomListState {
@ -134,7 +133,7 @@ class RoomListPresenter @Inject constructor(
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.MarkRoomAsFavorite -> coroutineScope.markRoomAsFavorite(event)
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event)
}
}
@ -170,28 +169,23 @@ class RoomListPresenter @Inject constructor(
isFavorite = AsyncData.Loading(),
)
contextMenuState.value = initialState
val room = client.getRoom(event.roomListRoomSummary.roomId)
if (room != null) {
room.notableTagsFlow
.distinctUntilChanged()
.onEach { tags ->
val newState = initialState.copy(isFavorite = AsyncData.Success(tags.isFavorite))
contextMenuState.value = newState
}
.launchIn(this)
} else {
contextMenuState.value = initialState.copy(isFavorite = AsyncData.Failure(IllegalStateException("Room not found")))
client.getRoom(event.roomListRoomSummary.roomId).use { room ->
if (room != null) {
room.notableTagsFlow
.distinctUntilChanged()
.onEach { tags ->
val newState = initialState.copy(isFavorite = AsyncData.Success(tags.isFavorite))
contextMenuState.value = newState
}
.collect()
} else {
contextMenuState.value = initialState.copy(isFavorite = AsyncData.Failure(IllegalStateException("Room not found")))
}
}
}
private fun CoroutineScope.markRoomAsFavorite(event: RoomListEvents.MarkRoomAsFavorite) = launch {
val room = client.getRoom(event.roomId)
if (room != null) {
val notableTags = RoomNotableTags(isFavorite = event.isFavorite);
room.updateNotableTags(notableTags)
}else {
Timber.w("Room ${event.roomId} not found, can't mark as favorite");
}
private fun CoroutineScope.setRoomIsFavorite(event: RoomListEvents.SetRoomIsFavorite) = launch {
setRoomIsFavorite(event.roomId, event.isFavorite)
}
private fun updateVisibleRange(range: IntRange) {
@ -204,5 +198,3 @@ class RoomListPresenter @Inject constructor(
client.roomListService.updateAllRoomsVisibleRange(extendedRange)
}
}

View file

@ -25,11 +25,14 @@ import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.features.roomactions.test.FakeSetRoomIsFavoriteAction
import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
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
@ -44,6 +47,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -55,6 +59,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@ -309,19 +314,29 @@ class RoomListPresenterTests {
@Test
fun `present - show context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
val room = FakeMatrixRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val summary = aRoomListRoomSummary
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem()
assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false, AsyncData.Success(false)))
}
room.updateNotableTags(RoomNotableTags(isFavorite = true))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false, AsyncData.Success(true)))
}
scope.cancel()
}
}
@ -329,7 +344,11 @@ class RoomListPresenterTests {
@Test
fun `present - hide context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
val room = FakeMatrixRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -341,7 +360,7 @@ class RoomListPresenterTests {
val shownState = awaitItem()
assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false))
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false, AsyncData.Success(false)))
shownState.eventSink(RoomListEvents.HideContextMenu)
val hiddenState = awaitItem()
@ -394,6 +413,22 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val setRoomIsFavoriteAction = FakeSetRoomIsFavoriteAction()
val presenter = createRoomListPresenter(setRoomIsFavoriteAction = setRoomIsFavoriteAction, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true))
setRoomIsFavoriteAction.assertCalled(1)
cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
@ -407,6 +442,7 @@ class RoomListPresenterTests {
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
encryptionService: EncryptionService = FakeEncryptionService(),
coroutineScope: CoroutineScope,
setRoomIsFavoriteAction: SetRoomIsFavoriteAction = FakeSetRoomIsFavoriteAction(),
) = RoomListPresenter(
client = client,
sessionVerificationService = sessionVerificationService,
@ -431,6 +467,7 @@ class RoomListPresenterTests {
encryptionService = encryptionService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
),
setRoomIsFavorite = setRoomIsFavoriteAction,
)
}

View file

@ -58,6 +58,9 @@ interface MatrixRoom : Closeable {
val roomInfoFlow: Flow<MatrixRoomInfo>
/**
* The current notable tags as a Flow.
*/
val notableTagsFlow: Flow<RoomNotableTags>
/**

View file

@ -16,6 +16,10 @@
package io.element.android.libraries.matrix.api.room.tags
/**
* Represents the notable tags of a room.
* @param isFavorite true if the room is marked as favorite.
*/
data class RoomNotableTags(
val isFavorite: Boolean,
val isFavorite: Boolean = false,
)

View file

@ -82,8 +82,8 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import uniffi.matrix_sdk_base.RoomNotableTags as RustRoomNotableTags
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
import uniffi.matrix_sdk_base.RoomNotableTags as RustRoomNotableTags
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
@ -118,7 +118,6 @@ class RustMatrixRoom(
override val notableTagsFlow: Flow<RoomNotableTags> = mxCallbackFlow {
innerRoom.subscribeToNotableTags(object : RoomNotableTagsListener {
override fun call(notableTags: RustRoomNotableTags) {
Timber.d("On notable tags update: $notableTags")
channel.trySend(notableTags.map())
}
})

View file

@ -25,7 +25,6 @@ import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) {
fun create(roomInfo: RoomInfo): RoomSummaryDetails {
val latestRoomMessage = roomInfo.latestEvent?.use {
roomMessageFactory.create(it)

View file

@ -28,7 +28,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSende
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList
import uniffi.matrix_sdk_ui.EventItemOrigin as RustEventItemOrigin
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction
@ -37,6 +36,7 @@ import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
import org.matrix.rustcomponents.sdk.Receipt as RustReceipt
import uniffi.matrix_sdk_ui.EventItemOrigin as RustEventItemOrigin
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {

View file

@ -627,7 +627,6 @@ fun aRoomInfo(
isPublic: Boolean = true,
isSpace: Boolean = false,
isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: String? = null,
alternativeAliases: List<String> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,